num2words2_core/lang_ro.rs
1//! Port of `lang_RO.py` (Romanian).
2//!
3//! Shape: **engine**. `Num2Word_RO` subclasses `lang_EUR.Num2Word_EUR` which
4//! subclasses `Num2Word_Base`. RO supplies `mid_numwords`/`low_numwords` and a
5//! `merge`, and inherits EUR's `set_high_numwords` + `gen_high_numwords`, so
6//! `Num2Word_Base.to_cardinal` drives `splitnum`/`clean` exactly as in
7//! `lang_en.rs`.
8//!
9//! Inheritance chain walked: `Num2Word_RO` → `Num2Word_EUR` (set_high_numwords,
10//! gen_high_numwords, setup's `high_numwords`) → `Num2Word_Base` (to_cardinal,
11//! splitnum, clean, verify_ordinal, to_year). `lang_EU` (Basque) is unrelated
12//! despite the similar name and is *not* in this chain.
13//!
14//! # Long scale
15//!
16//! EUR's `set_high_numwords` steps -6 and emits a GIGA/MEGA pair per stem:
17//! `cap = 3 + 6*len(high)` = 603 for RO's 100 stems, so cards run from
18//! 10^603 ("centiliard/e") down to 10^9 ("miliard/e") / 10^6 ("milion"), and
19//! `MAXVAL = 1000 * 10^603` = 10^606. Values are therefore genuinely BigInt.
20//!
21//! # Faithfully reproduced Python bugs
22//!
23//! This is a port, not a rewrite. Everything below is wrong-looking but is
24//! exactly what Python emits, and every item is pinned by a corpus row.
25//!
26//! 1. **`numwords_inflections` only covers 100 / 1000 / 10^6 / 10^9.** In
27//! `merge`'s `1 <= lnum < 10` branch, a right operand outside that set makes
28//! the branch `return (rtext, rnum)` — **discarding `ltext` entirely**.
29//! Hence `to_cardinal(10**12)` == "bilion" (not "un bilion"), and by the
30//! same path `to_cardinal(2 * 10**12)` == "bilion" too: the multiplier word
31//! is silently dropped for every card at 10^12 and above. Corpus rows:
32//! `10**12` → "bilion", `10**18` → "trilion".
33//! 2. **Un-inflected slash forms leak into output.** Because of bug 1 the raw
34//! card text is returned before `inflect` ever splits it, so the "word/plural"
35//! table syntax reaches the caller verbatim: `to_cardinal(10**15)` ==
36//! "biliard/e" and `to_cardinal(10**21)` == "triliard/e" (GIGA_SUFFIX is
37//! declared as "iliard/e"). Corpus-confirmed, slash and all.
38//! 3. **`merge` returns arithmetically wrong `num` values.** The `1 <= lnum < 10`
39//! branch returns `rnum` rather than `lnum * rnum` (so "trei sute" carries
40//! 100, not 300), and the `else` branch returns `lnum * rnum` where addition
41//! is meant (so "trei sute patruzeci și cinci" carries 100*45 = 4500). The
42//! numbers are only ever re-consumed by the next `merge`/`inflect` as a
43//! plural selector, and `pluralize` keys on `n % 100`, which happens to stay
44//! correct for these bogus values — the output is right by luck, not design.
45//! Preserved exactly, since a "fix" would change `pluralize`'s input.
46//! 4. **`inflect` ignores its `value` parameter.** It pluralizes on
47//! `side_effect` (the caller's `lnum`) and never reads `value`. See
48//! [`LangRo::inflect`].
49//! 5. **The "iliare" round-trip.** `inflect` builds the plural of "miliard/e"
50//! as `text[0][:-1] + text[1]` = "miliare", then a follow-up `elif` rewrites
51//! the "iliare" tail to GIGA_SUFFIX_I ("iliarde") to repair it — so the
52//! intermediate "miliare"/"de miliare" is a real, load-bearing state.
53//! 6. **`self.ords` is dead code.** `setup` builds an `ords` dict (containing a
54//! stray English key "three") that `to_ordinal` never reads — RO's
55//! `to_ordinal` is pure string concatenation. Not ported; it has no
56//! observable effect.
57//! 7. **`to_ordinal` just glues "lea" on.** `"al %slea" % cardinal`, with two
58//! prior blind `str.replace`s ("o sută"→"una sută", "o mie"→"una mie",
59//! in that order and applied to *every* occurrence). This yields
60//! `to_ordinal(100)` == "al una sutălea" and `to_ordinal(10**15)` ==
61//! "al biliard/elea" — the slash survives into the ordinal too.
62//!
63//! # Cross-call mutable state: `to_currency`'s `gen_numwords[1]` flip
64//!
65//! `Num2Word_RO.to_currency` mutates shared state that `to_cardinal` reads:
66//! `self.gen_numwords[1] = "una"` → `super().to_currency(...)` → revert to "o".
67//! `numwords_inflections[100]` and `[1000]` alias that same list object, so
68//! while the flag is set, `to_cardinal(100)` yields "una sută" instead of
69//! "o sută" — which is exactly why the corpus says `currency:EUR 100` is
70//! "una sută de euro" while `cardinal 100` is "o sută". `gen_numwords_n`
71//! (10^6 / 10^9) is a *different* list and is never touched, which is why
72//! `currency:EUR 1000000` stays "un milion de euro".
73//!
74//! The mutation is modelled here as [`RoUna`] — a borrowed view over the same
75//! `LangRo` whose only difference is that `merge` reads
76//! [`GEN_NUMWORDS_UNA`] where the pristine path reads [`GEN_NUMWORDS`].
77//! `to_currency` drives `currency::default_to_currency` through that view, so
78//! every `money_verbose`/`cents_verbose` → `to_cardinal` hop underneath sees
79//! "una", exactly as Python's mutated list does. Nothing is allocated per
80//! call and `LangRo` stays `Sync` (the registry hands out
81//! `&'static (dyn Lang + Sync)`, so `Cell` was not an option).
82//!
83//! # The poisoning on the error path, and where it survives
84//!
85//! Python's revert is not in a `try/finally`, so any raise inside
86//! `super().to_currency` leaves `gen_numwords[1] == "una"` forever. Because
87//! `CONVERTER_CLASSES` holds one *instance* per language, that poisoning is
88//! process-wide: after a single failed `to_currency`, `to_cardinal(100)`
89//! answers "una sută" for the rest of the interpreter's life. It is visible in
90//! the frozen corpus — the `w2n_cardinal` rows (emitted after the
91//! `currency:GBP` → NotImplementedError rows) spell 100 "una sută" and 1000
92//! "una mie", while the `cardinal` rows (emitted before any currency call)
93//! spell them "o sută" / "o mie".
94//!
95//! A scoped view cannot poison anything, so the question is whether the leak
96//! is observable *through the dispatcher*. Two exception types can escape RO's
97//! `to_currency`, and they behave differently:
98//!
99//! * **`NotImplementedError`** (currency absent from RO's `CURRENCY_FORMS` —
100//! everything but RON/EUR/USD). `__init__.py`'s Rust fast path wraps the
101//! call in `except NotImplementedError: pass` and falls through to the
102//! Python converter, which then runs `to_currency` for real and poisons the
103//! instance exactly as before. **Behaviour preserved**, by luck rather than
104//! design, but preserved.
105//! * **`OverflowError`** (`abs(val) >= MAXVAL`, i.e. 10^606, raised by
106//! `to_cardinal` underneath `_money_verbose`). The fast path does *not*
107//! catch this, so the Python converter never runs and the instance stays
108//! clean where Python would have poisoned it. The raised type still matches
109//! (verified: both sides give OverflowError for `to_currency(10**700, "RON")`).
110//! **This is the one divergence**, and it is only observable by making a
111//! >= 10^606 currency call and then inspecting a *later* `to_cardinal(100)`.
112//!
113//! Every corpus row matches either way: the poisoning only ever escapes into
114//! the four integer modes, which the corpus emits *before* the first currency
115//! call. Note `cheque:EUR`/`cheque:USD` are emitted after 12 *successful*
116//! `currency:*` rows, each of which reverts cleanly, so they legitimately read
117//! "O MIE …", not "UNA MIE …". Flagged in the report regardless.
118
119use crate::base::{
120 clean, set_low_numwords, set_mid_numwords, splitnum, Cards, Lang, N2WError, Node, Result,
121};
122use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
123use crate::floatpath::FloatValue;
124use num_bigint::BigInt;
125use num_integer::Integer;
126use num_traits::{One, Signed, ToPrimitive, Zero};
127use std::collections::HashMap;
128
129/// `GIGA_SUFFIX`. The "/e" is table syntax that `inflect` is meant to split —
130/// but bug 2 above lets it escape into output unsplit for 10^15 and beyond.
131const GIGA_SUFFIX: &str = "iliard/e";
132const MEGA_SUFFIX: &str = "ilion";
133/// `MEGA_SUFFIX_I` — "inflection for mi/billion follows different rule".
134const MEGA_SUFFIX_I: &str = "ilioane";
135const GIGA_SUFFIX_I: &str = "iliarde";
136
137/// `gen_numwords`: the feminine multiplier words used before "sută"/"mie".
138///
139/// Index 0 is "" and is never reached (`merge` guards with `1 <= lnum < 10`).
140/// Index 1 is the "o" that `to_currency` temporarily rewrites to "una" — see
141/// the cross-call-state note in the module docs. Pristine value kept here.
142const GEN_NUMWORDS: [&str; 10] = [
143 "", "o", "două", "trei", "patru", "cinci", "șase", "șapte", "opt", "nouă",
144];
145
146/// `gen_numwords` as it reads *during* `to_currency`, i.e. after
147/// `self.gen_numwords[1] = "una"`.
148///
149/// "romanian currency has a particularity for numeral: one". Differs from
150/// [`GEN_NUMWORDS`] at index 1 only; [`RoUna`] swaps this in for the duration
151/// of the currency call. Kept as a separate const rather than mutated in place
152/// because the registry requires `LangRo: Sync`.
153const GEN_NUMWORDS_UNA: [&str; 10] = [
154 "", "una", "două", "trei", "patru", "cinci", "șase", "șapte", "opt", "nouă",
155];
156
157/// `gen_numwords_n`: the masculine/neuter variant used before "milion"/"miliard".
158/// Differs from [`GEN_NUMWORDS`] only at index 1 ("un" vs "o").
159///
160/// `to_currency` does **not** touch this list — it is a distinct object in
161/// Python — so 10^6 and 10^9 keep saying "un" even in the "una" mode.
162const GEN_NUMWORDS_N: [&str; 10] = [
163 "", "un", "două", "trei", "patru", "cinci", "șase", "șapte", "opt", "nouă",
164];
165
166/// `Num2Word_RO.CURRENCY_FORMS`.
167///
168/// RO declares this in its own class body, so — unlike the 16 classes that
169/// read the dict `Num2Word_EN.__init__` mutates in place — it is a *separate*
170/// dict and sees none of English's additions. Confirmed against the live
171/// interpreter: `'CURRENCY_FORMS' in Num2Word_RO.__dict__` is `True`, and the
172/// runtime table is exactly these three codes. Everything else (GBP, JPY, KWD,
173/// BHD, INR, CNY, CHF, …) therefore raises NotImplementedError, which is what
174/// the corpus expects for all 84 of those rows.
175///
176/// All six tuples carry **three** forms, and RO's `pluralize` really does index
177/// `[2]` (the "de …" form) — dropping it would break every value >= 20.
178///
179/// Note `cenţi`: the source uses U+0163 (t with cedilla), not the U+021B
180/// (t with comma) used by `și`/`șase` elsewhere in the file. Copied verbatim.
181fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
182 const CENTS: [&str; 3] = ["cent", "cenţi", "de cenţi"];
183
184 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
185 m.insert(
186 "RON",
187 CurrencyForms::new(&["leu", "lei", "de lei"], &["ban", "bani", "de bani"]),
188 );
189 m.insert(
190 "EUR",
191 CurrencyForms::new(&["euro", "euro", "de euro"], &CENTS),
192 );
193 m.insert(
194 "USD",
195 CurrencyForms::new(&["dolar", "dolari", "de dolari"], &CENTS),
196 );
197 m
198}
199
200/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged — RO declares no
201/// `CURRENCY_ADJECTIVES` of its own, and nothing mutates EUR's (only
202/// `CURRENCY_FORMS` is written to in place by `Num2Word_EN.__init__`).
203/// Verified against the live interpreter.
204///
205/// Only RON and USD overlap RO's `CURRENCY_FORMS`, so the other 14 entries are
206/// dead: `adjective=True` on a code RO cannot render raises before the
207/// adjective is ever consulted. Kept because the inherited table is the ported
208/// data, not a curated subset.
209fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
210 [
211 ("AUD", "Australian"),
212 ("BYN", "Belarusian"),
213 ("CAD", "Canadian"),
214 ("EEK", "Estonian"),
215 ("USD", "US"),
216 ("RUB", "Russian"),
217 ("NOK", "Norwegian"),
218 ("MXN", "Mexican"),
219 ("RON", "Romanian"),
220 ("INR", "Indian"),
221 ("HUF", "Hungarian"),
222 ("ISK", "íslenskar"),
223 ("UZS", "Uzbekistan"),
224 ("SAR", "Saudi"),
225 ("JPY", "Japanese"),
226 ("KRW", "Korean"),
227 ]
228 .into_iter()
229 .collect()
230}
231
232/// Port of `Num2Word_EUR.gen_high_numwords`.
233///
234/// Kept local rather than reused from `lang_en.rs`: the porting contract is one
235/// file per language, and depending on a sibling language module would couple
236/// this file to a registry that is generated later.
237fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
238 // Python: [u + t for t in tens for u in units] — tens is the OUTER loop.
239 let mut out: Vec<String> = Vec::new();
240 for t in tens {
241 for u in units {
242 out.push(format!("{}{}", u, t));
243 }
244 }
245 out.reverse();
246
247 const REPLACEMENTS: &[(&str, &str)] = &[
248 ("novemn", "noven"),
249 ("novemo", "novo"),
250 ("octoo", "octo"),
251 ("quintd", "quind"),
252 ("quintn", "quin"),
253 ("quintq", "quinq"),
254 ("quints", "quins"),
255 ("quintt", "quint"),
256 ("quintv", "quinv"),
257 ("septenn", "septen"),
258 ("septent", "sept"),
259 ("sexn", "sen"),
260 ("sexs", "ses"),
261 ("tresd", "tred"),
262 ("tresn", "tren"),
263 ("tress", "tres"),
264 ("tresv", "trev"),
265 ("unno", "uno"),
266 ];
267 for (k, v) in REPLACEMENTS {
268 out = out.iter().map(|o| o.replace(k, v)).collect();
269 }
270 out.extend(lows.iter().map(|s| s.to_string()));
271 out
272}
273
274/// `rnum in self.numwords_inflections` plus the dict lookup, in one step.
275///
276/// Free rather than a method so that both the pristine table and the "una"
277/// table can be passed in; the membership test *and* the selected table both
278/// matter to `merge`.
279fn lookup_inflection(
280 table: &[(BigInt, &'static [&'static str; 10])],
281 key: &BigInt,
282) -> Option<&'static [&'static str; 10]> {
283 table.iter().find(|(k, _)| k == key).map(|(_, v)| *v)
284}
285
286/// RO's `errmsg_toobig`. The Python literal uses a backslash line
287/// continuation, so the real string reads "...pentru a fi convertit..."
288/// with a single space. Note Python tests `value >= MAXVAL` but the message
289/// text claims `abs(value) > MAXVAL` — kept verbatim.
290fn errmsg_toobig(v: &BigInt, maxval: &BigInt) -> N2WError {
291 N2WError::Overflow(format!(
292 "Numărul e prea mare pentru a fi convertit în cuvinte (abs({}) > {}).",
293 v, maxval
294 ))
295}
296
297/// `Num2Word_Base.to_cardinal` for RO (which does not override it).
298///
299/// Generic over the driving `Lang` because `splitnum`/`clean` dispatch `merge`
300/// through it: [`LangRo`] enters here with the pristine `gen_numwords`, and
301/// [`RoUna`] with the `to_currency` "una" flip. Reimplemented rather than
302/// delegated to `base::default_to_cardinal` for one reason only: RO overrides
303/// `errmsg_toobig` with a Romanian message. The success path is identical to
304/// the default.
305fn ro_to_cardinal<L: Lang + ?Sized>(lang: &L, value: &BigInt) -> Result<String> {
306 let mut out = String::new();
307 let mut v = value.clone();
308 if v.is_negative() {
309 v = v.abs();
310 // Python: "%s " % self.negword.strip() — "minus " → "minus".
311 out = format!("{} ", lang.negword().trim());
312 }
313
314 if &v >= lang.maxval() {
315 return Err(errmsg_toobig(&v, lang.maxval()));
316 }
317
318 // Unreachable in practice: card 0 exists, so splitnum matches any
319 // non-negative value below MAXVAL. Mapped to the same error for safety.
320 let tree = splitnum(lang, &v).ok_or_else(|| errmsg_toobig(&v, lang.maxval()))?;
321 let words = match clean(lang, tree) {
322 Node::Leaf(t, _) => t,
323 Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
324 };
325 Ok(lang.title(&format!("{}{}", out, words)))
326}
327
328pub struct LangRo {
329 cards: Cards,
330 maxval: BigInt,
331 /// `numwords_inflections`: {100: gen_numwords, 1000: gen_numwords,
332 /// 10^6: gen_numwords_n, 10^9: gen_numwords_n}. Membership *and* the
333 /// selected table both matter to `merge`.
334 numwords_inflections: Vec<(BigInt, &'static [&'static str; 10])>,
335 /// The same table as `numwords_inflections`, except that the two entries
336 /// aliasing `gen_numwords` (100 and 1000) point at [`GEN_NUMWORDS_UNA`].
337 /// This *is* Python's `self.gen_numwords[1] = "una"`, expressed without
338 /// mutation; see [`RoUna`].
339 numwords_inflections_una: Vec<(BigInt, &'static [&'static str; 10])>,
340 exclude_title: Vec<String>,
341 currency_forms: HashMap<&'static str, CurrencyForms>,
342 currency_adjectives: HashMap<&'static str, &'static str>,
343}
344
345impl Default for LangRo {
346 fn default() -> Self {
347 Self::new()
348 }
349}
350
351impl LangRo {
352 pub fn new() -> Self {
353 // Num2Word_EUR.setup()
354 let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
355 let units = [
356 "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
357 ];
358 let tens = [
359 "dec",
360 "vigint",
361 "trigint",
362 "quadragint",
363 "quinquagint",
364 "sexagint",
365 "septuagint",
366 "octogint",
367 "nonagint",
368 ];
369 let mut high = vec!["cent".to_string()];
370 high.extend(gen_high_numwords(&units, &tens, &lows));
371
372 let mut cards = Cards::new();
373
374 // Num2Word_EUR.set_high_numwords:
375 // cap = 3 + 6*len(high); zip(high, range(cap, 3, -6))
376 // cards[10**n] = word + GIGA_SUFFIX
377 // cards[10**(n-3)] = word + MEGA_SUFFIX
378 // zip() stops at the shorter sequence; here both are 100 long, but the
379 // `n > 3` guard reproduces range()'s stop condition regardless.
380 let cap: i64 = 3 + 6 * high.len() as i64;
381 let mut n = cap;
382 for word in high.iter() {
383 if n <= 3 {
384 break;
385 }
386 cards.insert(
387 BigInt::from(10u8).pow(n as u32),
388 format!("{}{}", word, GIGA_SUFFIX),
389 );
390 cards.insert(
391 BigInt::from(10u8).pow((n - 3) as u32),
392 format!("{}{}", word, MEGA_SUFFIX),
393 );
394 n -= 6;
395 }
396
397 set_mid_numwords(
398 &mut cards,
399 &[
400 (1000, "mie/i"),
401 (100, "sută/e"),
402 (90, "nouăzeci"),
403 (80, "optzeci"),
404 (70, "șaptezeci"),
405 (60, "șaizeci"),
406 (50, "cincizeci"),
407 (40, "patruzeci"),
408 (30, "treizeci"),
409 ],
410 );
411 set_low_numwords(
412 &mut cards,
413 &[
414 "douăzeci",
415 "nouăsprezece",
416 "optsprezece",
417 "șaptesprezece",
418 "șaisprezece",
419 "cincisprezece",
420 "paisprezece",
421 "treisprezece",
422 "doisprezece",
423 "unsprezece",
424 "zece",
425 "nouă",
426 "opt",
427 "șapte",
428 "șase",
429 "cinci",
430 "patru",
431 "trei",
432 "doi",
433 "unu",
434 "zero",
435 ],
436 );
437
438 // MAXVAL = 1000 * list(self.cards.keys())[0]; the OrderedDict's first
439 // key is the highest card (10^603), so MAXVAL == 10^606.
440 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
441
442 let numwords_inflections: Vec<(BigInt, &'static [&'static str; 10])> = vec![
443 (BigInt::from(100), &GEN_NUMWORDS),
444 (BigInt::from(1000), &GEN_NUMWORDS),
445 (BigInt::from(1_000_000i64), &GEN_NUMWORDS_N),
446 (BigInt::from(1_000_000_000i64), &GEN_NUMWORDS_N),
447 ];
448 // Only the two `gen_numwords` aliases flip; `gen_numwords_n` is a
449 // different list object in Python and `to_currency` never writes to it.
450 let numwords_inflections_una: Vec<(BigInt, &'static [&'static str; 10])> = vec![
451 (BigInt::from(100), &GEN_NUMWORDS_UNA),
452 (BigInt::from(1000), &GEN_NUMWORDS_UNA),
453 (BigInt::from(1_000_000i64), &GEN_NUMWORDS_N),
454 (BigInt::from(1_000_000_000i64), &GEN_NUMWORDS_N),
455 ];
456
457 LangRo {
458 cards,
459 maxval,
460 numwords_inflections,
461 numwords_inflections_una,
462 exclude_title: vec!["și".into(), "virgulă".into(), "minus".into()],
463 // Built once here, never per call: `to_currency` and `to_cheque`
464 // only ever read these.
465 currency_forms: build_currency_forms(),
466 currency_adjectives: build_currency_adjectives(),
467 }
468 }
469
470 /// `Num2Word_RO.pluralize`'s form selector, split out so that the
471 /// `inflect` call sites (which index a fixed 3-tuple and cannot fail) and
472 /// the `Lang::pluralize` hook (which takes an arbitrary-length slice and
473 /// must be able to raise IndexError) share one copy of the rule.
474 ///
475 /// `n % 100` uses Python's floor semantics, hence `mod_floor`. `n` is
476 /// non-negative on every path the four in-scope modes can reach (it is
477 /// always a `merge` `lnum`, derived from `abs(value)`), and the currency
478 /// path only ever passes `abs()`-ed parts — but the default
479 /// `side_effect=-1` in `inflect`'s signature would land here with -1, where
480 /// Python's `-1 % 100 == 99` (Rust's `%` gives -1). No call site uses that
481 /// default; `mod_floor` keeps it correct anyway.
482 fn plural_form(n: &BigInt) -> usize {
483 if n.is_one() {
484 0
485 } else if n.is_zero() {
486 1
487 } else {
488 let m = n.mod_floor(&BigInt::from(100));
489 if m > BigInt::zero() && m < BigInt::from(20) {
490 1
491 } else {
492 2
493 }
494 }
495 }
496
497 /// `Num2Word_RO.pluralize`, for `inflect`'s fixed 3-form tuples.
498 fn pluralize(n: &BigInt, forms: &[String; 3]) -> String {
499 forms[Self::plural_form(n)].clone()
500 }
501
502 /// `Num2Word_RO.merge`, with the `numwords_inflections` table threaded in.
503 ///
504 /// `inflections` is `self.numwords_inflections` on the pristine path and
505 /// `self.numwords_inflections_una` while `to_currency` holds
506 /// `gen_numwords[1] == "una"`. Threading it is the *only* difference from
507 /// the previously verified body; every branch is otherwise unchanged.
508 fn merge_with(
509 &self,
510 l: (&str, &BigInt),
511 r: (&str, &BigInt),
512 inflections: &[(BigInt, &'static [&'static str; 10])],
513 ) -> (String, BigInt) {
514 let (ltext, lnum) = l;
515 let (rtext, rnum) = r;
516 let one = BigInt::one();
517 let two = BigInt::from(2);
518 let ten = BigInt::from(10);
519 let hundred = BigInt::from(100);
520
521 // Python computes `rtext_i = self.inflect(rnum, rtext, lnum)` up front
522 // and then overwrites it inside most branches. `inflect` is pure, so
523 // computing it only where it is actually read is byte-identical; the
524 // fallback below is that original up-front expression.
525 if &one <= lnum && lnum < &ten {
526 match lookup_inflection(inflections, rnum) {
527 // Bug 1: ltext is dropped on the floor for any rnum outside
528 // {100, 1000, 10^6, 10^9} — this is what makes 10^12 "bilion"
529 // and 2*10^15 "biliard/e".
530 None => (rtext.to_string(), rnum.clone()),
531 Some(table) => {
532 let rtext_i = self.inflect(&(lnum * rnum), rtext, lnum);
533 // lnum is 1..=9 here, so the index is always in range.
534 let idx = lnum.to_usize().expect("1 <= lnum < 10");
535 // Bug 3: returns rnum, not lnum * rnum.
536 (format!("{} {}", table[idx], rtext_i), rnum.clone())
537 }
538 }
539 } else if &ten < lnum && lnum < &hundred {
540 if lnum.mod_floor(&ten).is_zero() {
541 if lookup_inflection(inflections, rnum).is_some() {
542 let rtext_i = self.inflect(&(lnum * rnum), rtext, lnum);
543 (format!("{} {}", ltext, rtext_i), lnum * rnum)
544 } else {
545 // Note: raw `rtext`, not the inflected form.
546 (format!("{} și {}", ltext, rtext), lnum + rnum)
547 }
548 } else {
549 let rtext_i = self.inflect(&(lnum * rnum), rtext, lnum);
550 // "douăzeci și doi" + "mie/i" → "douăzeci și două de mii".
551 // Python replaces EVERY "doi", so Rust's replace-all matches.
552 let ltext_i = if lnum.mod_floor(&ten) != two {
553 ltext.to_string()
554 } else {
555 ltext.replace("doi", "două")
556 };
557 (format!("{} {}", ltext_i, rtext_i), lnum * rnum)
558 }
559 } else {
560 // Reached for lnum == 0, lnum == 10 exactly, and lnum >= 100.
561 let rtext_i = if lookup_inflection(inflections, rnum).is_some() {
562 self.inflect(&(lnum * rnum), rtext, lnum)
563 } else {
564 self.inflect(rnum, rtext, lnum)
565 };
566 // Bug 3: multiplies where "o sută" + "patruzeci și cinci" means add.
567 (format!("{} {}", ltext, rtext_i), lnum * rnum)
568 }
569 }
570
571 /// `Num2Word_RO.inflect`.
572 ///
573 /// `_value` mirrors Python's `value` parameter, which the method **never
574 /// reads** — all pluralization keys on `side_effect` (bug 4). Kept in the
575 /// signature so the call sites stay legible against the original.
576 ///
577 /// The method is pure: no `self` mutation, despite the parameter name.
578 fn inflect(&self, _value: &BigInt, text: &str, side_effect: &BigInt) -> String {
579 let parts: Vec<&str> = text.split('/').collect();
580 let mut result = parts[0].to_string();
581
582 if parts.len() > 1 {
583 // Python: text[0][:-1] + text[1] — drop the last CHARACTER, not
584 // byte: "sută"[:-1] == "sut", and "ă" is two bytes in UTF-8.
585 let chars: Vec<char> = parts[0].chars().collect();
586 let stem: String = chars[..chars.len().saturating_sub(1)].iter().collect();
587 let plural = format!("{}{}", stem, parts[1]);
588 let forms = [
589 parts[0].to_string(),
590 plural.clone(),
591 format!("de {}", plural),
592 ];
593 result = Self::pluralize(side_effect, &forms);
594 }
595
596 let one = BigInt::one();
597 // "mega inflections also need de/no-de pluralization" — note this tests
598 // the WHOLE accumulated string, so a merged phrase that happens to end
599 // in "...ilion" (e.g. the right operand "un milion" under a 10^12 left)
600 // gets mangled into "de un milioane". Faithful: same test as Python.
601 if side_effect > &one && result.ends_with(MEGA_SUFFIX) {
602 let inflected = result.replace(MEGA_SUFFIX, MEGA_SUFFIX_I);
603 let forms = [
604 result.clone(),
605 inflected.clone(),
606 format!("de {}", inflected),
607 ];
608 result = Self::pluralize(side_effect, &forms);
609 } else if side_effect > &one && result.ends_with("iliare") {
610 // Repairs the "miliard"→"miliare" mis-plural built above (bug 5).
611 result = result.replace("iliare", GIGA_SUFFIX_I);
612 }
613 result
614 }
615
616 /// `Num2Word_Base.verify_ordinal`.
617 ///
618 /// The float check (`value == int(value)`) cannot fail for BigInt input;
619 /// only the `abs(value) == value` check is reachable. Both raise TypeError
620 /// in Python (errmsg_negord), matching the corpus's `TypeError` rows for
621 /// every negative ordinal / ordinal_num.
622 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
623 if value.is_negative() {
624 return Err(N2WError::Type(format!(
625 "Cannot treat negative num {} as ordinal.",
626 value
627 )));
628 }
629 Ok(())
630 }
631}
632
633impl Lang for LangRo {
634 /// This language's own `to_currency(currency=...)` default,
635 /// read from the live Python signature. Only 44 of 156 use EUR.
636 fn default_currency(&self) -> &str {
637 "RON"
638 }
639
640 /// This language's own `to_currency(separator=...)` default,
641 /// read from the live Python signature. Base's is ",", but only
642 /// 36 of 149 languages actually use it — most default to " " or a
643 /// conjunction, so inheriting Base's comma silently corrupts them.
644 fn default_separator(&self) -> &str {
645 " și"
646 }
647
648 fn cards(&self) -> &Cards {
649 &self.cards
650 }
651 fn maxval(&self) -> &BigInt {
652 &self.maxval
653 }
654 fn negword(&self) -> &str {
655 "minus "
656 }
657 fn pointword(&self) -> &str {
658 "virgulă"
659 }
660 fn exclude_title(&self) -> &[String] {
661 &self.exclude_title
662 }
663
664 /// `Num2Word_RO.merge`, against the pristine `gen_numwords`.
665 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
666 self.merge_with(l, r, &self.numwords_inflections)
667 }
668
669 /// `Num2Word_Base.to_cardinal` (RO does not override it).
670 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
671 ro_to_cardinal(self, value)
672 }
673
674 /// `Num2Word_RO.to_ordinal`.
675 ///
676 /// Pure concatenation — `self.ords` is never consulted (bug 6). The two
677 /// replaces are order-dependent and global: on "o mie o sută" the hundreds
678 /// pass runs first, giving "o mie una sută", and the thousands pass then
679 /// yields "una mie una sută" → "al una mie una sutălea".
680 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
681 self.verify_ordinal(value)?;
682 if value.is_one() {
683 return Ok("primul".to_string());
684 }
685 let cardinal = self.to_cardinal(value)?;
686 let cardinal = cardinal.replace("o sută", "una sută");
687 let cardinal = cardinal.replace("o mie", "una mie");
688 Ok(format!("al {}lea", cardinal))
689 }
690
691 /// `Num2Word_RO.to_ordinal_num`.
692 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
693 self.verify_ordinal(value)?;
694 if value.is_one() {
695 return Ok("1-ul".to_string());
696 }
697 Ok(format!("al {}-lea", value))
698 }
699
700 /// `Num2Word_RO.to_year`.
701 ///
702 /// Python's signature is `to_year(self, val, suffix=None, longval=True)`;
703 /// `super().to_year` is `Num2Word_Base.to_year(value, **kwargs)`, which
704 /// swallows `longval` and just returns `to_cardinal(value)`. The trait
705 /// carries no suffix parameter, so this is the `suffix=None` path — the
706 /// only one the dispatcher's year mode exercises.
707 fn to_year(&self, value: &BigInt) -> Result<String> {
708 let mut result = self.to_cardinal(value)?;
709 let mut suffix: Option<&str> = None;
710 // negword is "minus " (with the trailing space); to_cardinal emits
711 // exactly that prefix for negatives, so the test hits. str.replace is
712 // global in both languages, matching Python's `.replace(negword, "")`.
713 if result.starts_with(self.negword()) {
714 result = result.replace(self.negword(), "");
715 suffix = Some("î.Hr.");
716 }
717 if let Some(s) = suffix {
718 result = format!("{} {}", result, s);
719 }
720 Ok(result)
721 }
722
723 /// `to_ordinal(float/Decimal)`. `verify_ordinal` raises TypeError for a
724 /// non-integral value first, then for negatives — the negative check is
725 /// reproduced by `to_ordinal(BigInt)`'s own verify. `-0.0` passes both
726 /// (`abs(-0.0) == -0.0`) and renders as "al zerolea".
727 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
728 match value.as_whole_int() {
729 Some(i) => self.to_ordinal(&i),
730 None => Err(N2WError::Type(
731 "Cannot treat float as ordinal.".into(),
732 )),
733 }
734 }
735
736 /// `to_ordinal_num(float/Decimal)`: `verify_ordinal`, then the literal
737 /// `"1-ul"` when `value == 1` (so `1.0` → "1-ul", not "1.0-ul"), else
738 /// `"al %s-lea" % value` with Python's `str(value)`.
739 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
740 match value.as_whole_int() {
741 Some(i) => {
742 if i.is_negative() {
743 return Err(N2WError::Type(format!(
744 "Cannot treat negative num {} as ordinal.",
745 repr_str
746 )));
747 }
748 if i.is_one() {
749 return Ok("1-ul".to_string());
750 }
751 Ok(format!("al {}-lea", repr_str))
752 }
753 None => Err(N2WError::Type(
754 "Cannot treat float as ordinal.".into(),
755 )),
756 }
757 }
758
759 /// `to_year(float/Decimal)`: base's `to_year` is `to_cardinal(val)`;
760 /// RO then strips a leading negword (a *global* `str.replace`) and
761 /// appends " î.Hr." — "unu virgulă cinci î.Hr." for -1.5.
762 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
763 let mut result = self.cardinal_float_entry(value, None)?;
764 let mut suffix: Option<&str> = None;
765 if result.starts_with(self.negword()) {
766 result = result.replace(self.negword(), "");
767 suffix = Some("î.Hr.");
768 }
769 if let Some(s) = suffix {
770 result = format!("{} {}", result, s);
771 }
772 Ok(result)
773 }
774
775 // ---- currency -------------------------------------------------------
776 //
777 // RO overrides `CURRENCY_FORMS`, `pluralize` and `to_currency`. It
778 // inherits `CURRENCY_ADJECTIVES` from `Num2Word_EUR` and everything else
779 // (`to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse`) from
780 // `Num2Word_Base` unchanged, so the trait defaults already mirror those.
781 //
782 // `currency_precision` is *not* overridden on purpose: `Num2Word_Base`'s
783 // `CURRENCY_PRECISION` is `{}` and neither RO nor EUR defines one (EN's
784 // `self.CURRENCY_PRECISION = {...}` rebinds an *instance* attribute and so
785 // does not leak). `{}.get(code, 100)` == 100 for every code, which is
786 // exactly the trait default. RO therefore has no 3-decimal and no
787 // 0-decimal currency: KWD/BHD raise NotImplementedError before precision
788 // is consulted, and `default_to_currency`'s `divisor == 1` branch is
789 // unreachable — JPY, absent from RO's table, raises too.
790
791 fn lang_name(&self) -> &str {
792 "Num2Word_RO"
793 }
794
795 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
796 self.currency_forms.get(code)
797 }
798
799 fn currency_adjective(&self, code: &str) -> Option<&str> {
800 self.currency_adjectives.get(code).copied()
801 }
802
803 /// `Num2Word_RO.pluralize` — the three-way Romanian rule:
804 /// 1 → form 0, {0} ∪ {n : 0 < n % 100 < 20} → form 1, else form 2 ("de …").
805 ///
806 /// Python indexes the tuple directly, so a form list shorter than the
807 /// selected index raises IndexError. Every entry in RO's table carries all
808 /// three forms, so that is unreachable — but it is mapped to `Index`
809 /// rather than panicking so the exception type survives if the table
810 /// changes. `prefix_currency` (the `adjective=True` path) preserves arity,
811 /// so it cannot shorten a tuple either.
812 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
813 forms
814 .get(Self::plural_form(n))
815 .cloned()
816 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
817 }
818
819 /// `Num2Word_RO.to_currency`.
820 ///
821 /// ```python
822 /// def to_currency(self, val, currency="RON", cents=False,
823 /// separator=" și", adjective=False):
824 /// self.gen_numwords[1] = "una"
825 /// result = super().to_currency(val, currency, True, separator, adjective)
826 /// self.gen_numwords[1] = "o"
827 /// return (result.replace("unu leu", "un leu")
828 /// .replace("unu ban", "un ban")
829 /// .replace(separator + " zero bani", ""))
830 /// ```
831 ///
832 /// Four things to note, all load-bearing:
833 ///
834 /// 1. **`cents` is accepted and thrown away.** The super call hardcodes
835 /// `True`, so `to_currency(12.34, "EUR", cents=False)` still spells the
836 /// cents out ("… și treizeci și patru de cenţi") instead of taking
837 /// `_cents_terse`'s "34". Verified against the live interpreter.
838 /// 2. **The three replaces are RON-only and order-dependent.** "unu leu" →
839 /// "un leu" runs before "unu ban" → "un ban", and the zero-cents strip
840 /// matches the literal word "bani" — so `1.0 RON` collapses all the way
841 /// to "un leu" ("unu leu și zero bani" → "un leu și zero bani" → "un
842 /// leu") while `1.0 EUR` keeps its tail as "unu euro și zero cenţi".
843 /// The replaces are also blind to the adjective prefix: `1 RON` with
844 /// `adjective=True` gives "unu Romanian leu", not "un Romanian leu".
845 /// 3. **The strip is keyed on the caller's `separator`**, not on RO's
846 /// default, so `separator=","` strips ", zero bani" and still yields
847 /// "un leu". Both confirmed against the live interpreter.
848 /// 4. **"unu ban" bleeds into "unu bani".** `str.replace` is a substring
849 /// op, and "unu ban" is a prefix of "unu bani", so the fractional-cents
850 /// branch — which takes `cr2[1]` ("bani") unconditionally, without
851 /// consulting `pluralize` — gets rewritten mid-word:
852 /// `to_currency(1.011, "RON")` == "un leu și unu virgulă **un bani**",
853 /// which is not grammatical Romanian. Ported as-is.
854 fn to_currency(
855 &self,
856 val: &CurrencyValue,
857 currency: &str,
858 // Accepted and never read — the super call hardcodes `cents=True`.
859 _cents: bool,
860 separator: Option<&str>,
861 adjective: bool,
862 ) -> Result<String> {
863 // The trait hands us None when the caller omitted `separator=`;
864 // resolve it through this language's own default (" și").
865 let separator = separator.unwrap_or(self.default_separator());
866
867 // `self.gen_numwords[1] = "una"` … `super().to_currency(...)`.
868 // On the Err path Python would skip its revert and poison the shared
869 // instance; the scoped view cannot — see the module docs.
870 let result = default_to_currency(&RoUna(self), val, currency, true, separator, adjective)?;
871
872 // `self.gen_numwords[1] = "o"` — implicit: the view ends here.
873 Ok(result
874 .replace("unu leu", "un leu")
875 .replace("unu ban", "un ban")
876 // "if the romanian low text is 0, it is not usually printed"
877 .replace(&format!("{} zero bani", separator), ""))
878 }
879}
880
881/// `Num2Word_RO` as it reads *inside* `to_currency`, i.e. with
882/// `self.gen_numwords[1] == "una"`.
883///
884/// Python expresses the flip by mutating the shared list and reverting after
885/// `super().to_currency` returns. The registry hands out
886/// `&'static (dyn Lang + Sync)`, so a `Cell` on `LangRo` is not available;
887/// instead this borrows the very same `LangRo` and overrides the single method
888/// the flip can reach — `merge` — to read `numwords_inflections_una`. Every
889/// other hook forwards, so `default_to_currency`'s
890/// `money_verbose`/`cents_verbose` → `to_cardinal` hops re-enter `splitnum` /
891/// `clean` here and dispatch `merge` on this view.
892///
893/// Borrowed, never cloned: constructing one is a pointer copy, so the
894/// per-call cost is nil and the cards table is shared.
895struct RoUna<'a>(&'a LangRo);
896
897impl Lang for RoUna<'_> {
898 // ---- the flip -------------------------------------------------------
899
900 /// The one method that differs: `gen_numwords[1]` reads "una", not "o".
901 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
902 self.0.merge_with(l, r, &self.0.numwords_inflections_una)
903 }
904
905 /// Re-entered by `money_verbose`/`cents_verbose`; drives `merge` above.
906 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
907 ro_to_cardinal(self, value)
908 }
909
910 // ---- everything else forwards to the real converter ------------------
911
912 fn cards(&self) -> &Cards {
913 self.0.cards()
914 }
915 fn maxval(&self) -> &BigInt {
916 self.0.maxval()
917 }
918 fn negword(&self) -> &str {
919 self.0.negword()
920 }
921 fn pointword(&self) -> &str {
922 self.0.pointword()
923 }
924 fn is_title(&self) -> bool {
925 self.0.is_title()
926 }
927 fn exclude_title(&self) -> &[String] {
928 self.0.exclude_title()
929 }
930 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
931 self.0.to_ordinal(value)
932 }
933 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
934 self.0.to_ordinal_num(value)
935 }
936 fn to_year(&self, value: &BigInt) -> Result<String> {
937 self.0.to_year(value)
938 }
939 fn lang_name(&self) -> &str {
940 self.0.lang_name()
941 }
942 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
943 self.0.currency_forms(code)
944 }
945 fn currency_adjective(&self, code: &str) -> Option<&str> {
946 self.0.currency_adjective(code)
947 }
948 fn currency_precision(&self, code: &str) -> i64 {
949 self.0.currency_precision(code)
950 }
951 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
952 self.0.pluralize(n, forms)
953 }
954 fn default_separator(&self) -> &str {
955 self.0.default_separator()
956 }
957 fn default_currency(&self) -> &str {
958 self.0.default_currency()
959 }
960 // `to_currency` is deliberately NOT forwarded: `LangRo::to_currency` is
961 // what constructs this view, and forwarding would recurse. Nothing calls
962 // it on a `RoUna` — `LangRo::to_currency` invokes `default_to_currency`
963 // directly, which is `super().to_currency` in Python. `to_cheque` is not
964 // forwarded either: RO's `to_cheque` never sets the flag, so it must not
965 // be reachable through this view.
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971 use crate::currency::CurrencyValue;
972 use bigdecimal::BigDecimal;
973 use std::str::FromStr;
974
975 /// `arg` is `repr(value)` on the Python side, so `is_int` / `has_decimal`
976 /// are derived the same way the binding derives them: a true `int` has no
977 /// dot, and `isinstance(val, float)` is true for everything that does.
978 fn cur(arg: &str, code: &str) -> Result<String> {
979 let is_int = !arg.contains('.');
980 let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
981 LangRo::new().to_currency(&v, code, true, None, false)
982 }
983
984 fn cheque(arg: &str, code: &str) -> Result<String> {
985 LangRo::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
986 }
987
988 fn is_not_impl(r: Result<String>) -> bool {
989 matches!(r, Err(N2WError::NotImplemented(_)))
990 }
991
992 /// Every `currency:EUR` and `currency:USD` row of the frozen corpus.
993 #[test]
994 fn corpus_currency_eur_usd() {
995 let rows: &[(&str, &str, &str)] = &[
996 ("0", "EUR", "zero euro"),
997 ("1", "EUR", "unu euro"),
998 ("2", "EUR", "doi euro"),
999 ("100", "EUR", "una sută de euro"),
1000 ("12.34", "EUR", "doisprezece euro și treizeci și patru de cenţi"),
1001 ("0.01", "EUR", "zero euro și unu cent"),
1002 ("1.0", "EUR", "unu euro și zero cenţi"),
1003 ("99.99", "EUR", "nouăzeci și nouă de euro și nouăzeci și nouă de cenţi"),
1004 (
1005 "1234.56",
1006 "EUR",
1007 "una mie două sute treizeci și patru de euro și cincizeci și șase de cenţi",
1008 ),
1009 ("-12.34", "EUR", "minus doisprezece euro și treizeci și patru de cenţi"),
1010 ("1000000", "EUR", "un milion de euro"),
1011 ("0.5", "EUR", "zero euro și cincizeci de cenţi"),
1012 ("0", "USD", "zero dolari"),
1013 ("1", "USD", "unu dolar"),
1014 ("2", "USD", "doi dolari"),
1015 ("100", "USD", "una sută de dolari"),
1016 ("12.34", "USD", "doisprezece dolari și treizeci și patru de cenţi"),
1017 ("0.01", "USD", "zero dolari și unu cent"),
1018 ("1.0", "USD", "unu dolar și zero cenţi"),
1019 ("99.99", "USD", "nouăzeci și nouă de dolari și nouăzeci și nouă de cenţi"),
1020 (
1021 "1234.56",
1022 "USD",
1023 "una mie două sute treizeci și patru de dolari și cincizeci și șase de cenţi",
1024 ),
1025 ("-12.34", "USD", "minus doisprezece dolari și treizeci și patru de cenţi"),
1026 ("1000000", "USD", "un milion de dolari"),
1027 ("0.5", "USD", "zero dolari și cincizeci de cenţi"),
1028 ];
1029 for (arg, code, want) in rows {
1030 assert_eq!(cur(arg, code).unwrap(), *want, "currency:{} {}", code, arg);
1031 }
1032 }
1033
1034 /// Both successful `cheque:*` rows. Note "O MIE", not "UNA MIE": `to_cheque`
1035 /// never sets the "una" flag, so it renders through the pristine table.
1036 #[test]
1037 fn corpus_cheque() {
1038 assert_eq!(
1039 cheque("1234.56", "EUR").unwrap(),
1040 "O MIE DOUĂ SUTE TREIZECI ȘI PATRU AND 56/100 DE EURO"
1041 );
1042 assert_eq!(
1043 cheque("1234.56", "USD").unwrap(),
1044 "O MIE DOUĂ SUTE TREIZECI ȘI PATRU AND 56/100 DE DOLARI"
1045 );
1046 }
1047
1048 /// RO declares its own `CURRENCY_FORMS`, so it never sees the codes
1049 /// `Num2Word_EN.__init__` adds to the dict `Num2Word_EUR` shares. All 84
1050 /// currency rows and 7 cheque rows for these codes expect NotImplementedError.
1051 #[test]
1052 fn corpus_unimplemented_codes() {
1053 let args = [
1054 "0", "1", "2", "100", "12.34", "0.01", "1.0", "99.99", "1234.56", "-12.34",
1055 "1000000", "0.5",
1056 ];
1057 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1058 for arg in args {
1059 assert!(is_not_impl(cur(arg, code)), "currency:{} {}", code, arg);
1060 }
1061 assert!(is_not_impl(cheque("1234.56", code)), "cheque:{}", code);
1062 }
1063 }
1064
1065 /// The exact Python message, from the live interpreter.
1066 #[test]
1067 fn not_implemented_message() {
1068 match cur("1", "GBP") {
1069 Err(N2WError::NotImplemented(m)) => {
1070 assert_eq!(m, "Currency code \"GBP\" not implemented for \"Num2Word_RO\"")
1071 }
1072 other => panic!("want NotImplemented, got {:?}", other),
1073 }
1074 match cheque("1234.56", "GBP") {
1075 Err(N2WError::NotImplemented(m)) => {
1076 assert_eq!(m, "Currency code \"GBP\" not implemented for \"Num2Word_RO\"")
1077 }
1078 other => panic!("want NotImplemented, got {:?}", other),
1079 }
1080 }
1081
1082 /// RON is RO's `default_currency` but no corpus row exercises it, so these
1083 /// are pinned against the live interpreter instead. They are the only rows
1084 /// that reach the three `str.replace`s in `to_currency`.
1085 #[test]
1086 fn ron_replaces() {
1087 let rows: &[(&str, &str)] = &[
1088 ("0", "zero lei"),
1089 ("1", "un leu"), // "unu leu" -> "un leu"
1090 ("2", "doi lei"),
1091 ("100", "una sută de lei"),
1092 ("12.34", "doisprezece lei și treizeci și patru de bani"),
1093 ("0.01", "zero lei și un ban"), // "unu ban" -> "un ban"
1094 ("1.0", "un leu"), // both replaces + the zero-bani strip
1095 ("0.0", "zero lei"), // zero-bani strip alone
1096 ("2.0", "doi lei"),
1097 ("1.01", "un leu și un ban"), // order: leu first, then ban
1098 ("21.0", "douăzeci și unu de lei"),
1099 ("-1.0", "minus un leu"),
1100 ("99.99", "nouăzeci și nouă de lei și nouăzeci și nouă de bani"),
1101 (
1102 "1234.56",
1103 "una mie două sute treizeci și patru de lei și cincizeci și șase de bani",
1104 ),
1105 ("-12.34", "minus doisprezece lei și treizeci și patru de bani"),
1106 ("1000000", "un milion de lei"),
1107 ("0.5", "zero lei și cincizeci de bani"),
1108 ];
1109 for (arg, want) in rows {
1110 assert_eq!(cur(arg, "RON").unwrap(), *want, "currency:RON {}", arg);
1111 }
1112 }
1113
1114 /// The fractional-cents branch, which `default_to_currency` renders via
1115 /// `cardinal_from_decimal` and which takes `cr2[1]` unconditionally.
1116 ///
1117 /// `1.011 RON` is the sharpest case: "unu leu și unu virgulă unu bani" has
1118 /// *both* replaces fire, the second one mid-word ("unu ban" is a prefix of
1119 /// "unu bani"), yielding the ungrammatical "un bani". All pinned against
1120 /// the live interpreter.
1121 #[test]
1122 fn fractional_cents() {
1123 let rows: &[(&str, &str, &str)] = &[
1124 ("1.011", "RON", "un leu și unu virgulă un bani"),
1125 ("0.005", "RON", "zero lei și zero virgulă cinci bani"),
1126 ("1.005", "RON", "un leu și zero virgulă cinci bani"),
1127 ("2.675", "RON", "doi lei și șaizeci și șapte virgulă cinci bani"),
1128 ("0.005", "EUR", "zero euro și zero virgulă cinci cenţi"),
1129 ("2.675", "EUR", "doi euro și șaizeci și șapte virgulă cinci cenţi"),
1130 ("1.005", "USD", "unu dolar și zero virgulă cinci cenţi"),
1131 ];
1132 for (arg, code, want) in rows {
1133 assert_eq!(cur(arg, code).unwrap(), *want, "{} {}", code, arg);
1134 }
1135 }
1136
1137 /// The zero-cents strip is keyed on the *caller's* separator, not on RO's
1138 /// default — so an override still collapses `1.0 RON` to "un leu".
1139 #[test]
1140 fn separator_is_used_by_the_strip() {
1141 let l = LangRo::new();
1142 let v = CurrencyValue::parse("1.0", false, true, true).unwrap();
1143 assert_eq!(l.to_currency(&v, "RON", true, Some(" plus"), false).unwrap(), "un leu");
1144 assert_eq!(l.to_currency(&v, "RON", true, Some(","), false).unwrap(), "un leu");
1145 // Omitted separator= resolves through default_separator() == " și".
1146 assert_eq!(l.to_currency(&v, "EUR", true, None, false).unwrap(), "unu euro și zero cenţi");
1147 }
1148
1149 /// `cents=False` is accepted and discarded: the super call hardcodes True,
1150 /// so the cents stay verbose instead of falling back to `_cents_terse`.
1151 #[test]
1152 fn cents_flag_is_ignored() {
1153 let l = LangRo::new();
1154 let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1155 assert_eq!(
1156 l.to_currency(&v, "EUR", false, None, false).unwrap(),
1157 "doisprezece euro și treizeci și patru de cenţi"
1158 );
1159 }
1160
1161 /// `CURRENCY_ADJECTIVES` is inherited from `Num2Word_EUR`. Note the
1162 /// replaces are blind to the prefix: "unu Romanian leu" stays "unu".
1163 #[test]
1164 fn adjective_prefix() {
1165 let l = LangRo::new();
1166 let one = CurrencyValue::parse("1", true, false, false).unwrap();
1167 assert_eq!(l.to_currency(&one, "RON", true, None, true).unwrap(), "unu Romanian leu");
1168 let one_f = CurrencyValue::parse("1.0", false, true, true).unwrap();
1169 assert_eq!(l.to_currency(&one_f, "RON", true, None, true).unwrap(), "unu Romanian leu");
1170 let two = CurrencyValue::parse("2", true, false, false).unwrap();
1171 assert_eq!(l.to_currency(&two, "USD", true, None, true).unwrap(), "doi US dolari");
1172 // EUR has no adjective; the flag is a no-op.
1173 assert_eq!(l.to_currency(&one, "EUR", true, None, true).unwrap(), "unu euro");
1174 }
1175
1176 /// The "una" flip must stay confined to `to_currency`: the four integer
1177 /// modes keep the pristine "o", which is what their corpus rows say.
1178 #[test]
1179 fn una_flip_does_not_leak() {
1180 let l = LangRo::new();
1181 let hundred = BigInt::from(100);
1182 assert_eq!(l.to_cardinal(&hundred).unwrap(), "o sută");
1183 assert_eq!(l.to_cardinal(&BigInt::from(1000)).unwrap(), "o mie");
1184 assert_eq!(l.to_cardinal(&BigInt::from(1234)).unwrap(), "o mie două sute treizeci și patru");
1185 assert_eq!(l.to_ordinal(&hundred).unwrap(), "al una sutălea");
1186
1187 // ... including after a currency call that succeeds ...
1188 let v = CurrencyValue::parse("100", true, false, false).unwrap();
1189 assert_eq!(l.to_currency(&v, "EUR", true, None, false).unwrap(), "una sută de euro");
1190 assert_eq!(l.to_cardinal(&hundred).unwrap(), "o sută");
1191
1192 // ... and after one that raises, where Python would stay poisoned.
1193 assert!(is_not_impl(l.to_currency(&v, "GBP", true, None, false)));
1194 assert_eq!(l.to_cardinal(&hundred).unwrap(), "o sută");
1195 assert_eq!(
1196 cheque("1234.56", "EUR").unwrap(),
1197 "O MIE DOUĂ SUTE TREIZECI ȘI PATRU AND 56/100 DE EURO"
1198 );
1199 }
1200
1201 /// `gen_numwords_n` is a distinct list in Python; the flip never reaches it.
1202 #[test]
1203 fn mega_giga_keep_un() {
1204 let l = LangRo::new();
1205 for (arg, want) in [("1000000", "un milion de euro"), ("1000000000", "un miliard de euro")] {
1206 let v = CurrencyValue::parse(arg, true, false, false).unwrap();
1207 assert_eq!(l.to_currency(&v, "EUR", true, None, false).unwrap(), want);
1208 }
1209 }
1210
1211 /// RO's three-way plural rule, exercised through the unit word.
1212 ///
1213 /// Note 101: form 0 is gated on `n == 1`, not on `n % 100 == 1`, so 101
1214 /// falls through to form 1 and takes the *plural* — "una sută unu dolari",
1215 /// not "…unu dolar". Confirmed against the live interpreter.
1216 #[test]
1217 fn plural_rule() {
1218 for (arg, want) in [
1219 ("1", "unu dolar"), // form 0 (n == 1)
1220 ("0", "zero dolari"), // form 1 (n == 0)
1221 ("19", "nouăsprezece dolari"), // form 1 (0 < n % 100 < 20)
1222 ("101", "una sută unu dolari"), // form 1 (n % 100 == 1, but n != 1)
1223 ("20", "douăzeci de dolari"), // form 2
1224 ("100", "una sută de dolari"), // form 2 (n % 100 == 0)
1225 ] {
1226 let v = CurrencyValue::parse(arg, true, false, false).unwrap();
1227 let got = LangRo::new().to_currency(&v, "USD", true, None, false).unwrap();
1228 assert_eq!(got, want, "{}", arg);
1229 }
1230 }
1231}