Skip to main content

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}