Skip to main content

skey_engine/
engine.rs

1//! Vietnamese IME engine — Letter-based accumulator model.
2//! Each keystroke produces a `Letter` struct. Modifier keys (w, tone, z)
3//! mutate the last matching vowel in place. UTF-8 output via precomputed tables.
4
5use crate::tables;
6
7#[derive(Clone, Copy, Default)]
8struct Letter {
9    c: char,
10    is_vowel: bool,
11    variant: u8,
12    tone: u8,
13    is_dstroke: bool,
14    upper: bool,
15    from_w: bool,
16}
17
18impl Letter {
19    fn new(c: char, upper: bool) -> Self {
20        let lc = c.to_ascii_lowercase();
21        Letter {
22            c: lc,
23            is_vowel: tables::is_vowel(lc),
24            variant: 0,
25            tone: 0,
26            is_dstroke: false,
27            upper,
28            from_w: false,
29        }
30    }
31}
32
33fn emit(ls: &[Letter]) -> String {
34    let mut out = String::with_capacity(ls.len() * 3);
35    for lt in ls {
36        if lt.is_dstroke {
37            out.push_str(if lt.upper {
38                tables::D_STROKE_UP
39            } else {
40                tables::D_STROKE_LO
41            });
42        } else if lt.is_vowel {
43            out.push_str(tables::vowel_utf8(lt.c, lt.variant, lt.tone, lt.upper));
44        } else {
45            out.push(if lt.upper {
46                lt.c.to_ascii_uppercase()
47            } else {
48                lt.c
49            });
50        }
51    }
52    out
53}
54
55fn last_vowel_index(ls: &[Letter]) -> Option<usize> {
56    ls.iter().rposition(|lt| lt.is_vowel)
57}
58fn last_d_index(ls: &[Letter]) -> Option<usize> {
59    ls.iter()
60        .rposition(|lt| lt.c == 'd' && !lt.is_vowel && !lt.is_dstroke)
61}
62
63/// Find the last vowel that can be modified by `w`: a→ă, o→ơ, u→ư, e→ê.
64/// Used by the `w` key handler so that e.g. "toi" + w finds 'o' (not 'i').
65fn last_w_target_index(ls: &[Letter]) -> Option<usize> {
66    ls.iter()
67        .rposition(|lt| lt.is_vowel && matches!(lt.c, 'a' | 'o' | 'u' | 'e'))
68}
69
70fn tone_vowel_index(ls: &[Letter], _modern: bool) -> Option<usize> {
71    let end = last_vowel_index(ls)?;
72    let mut start = end;
73    while start > 0 && ls[start - 1].is_vowel {
74        start -= 1;
75    }
76    if start < end {
77        if ls[start].c == 'u' && start > 0 && ls[start - 1].c == 'q' {
78            start += 1;
79        } else if ls[start].c == 'i' && start > 0 && ls[start - 1].c == 'g' {
80            start += 1;
81        }
82    }
83    if start > end {
84        start = end;
85    }
86    for i in (start..=end).rev() {
87        if ls[i].variant != 0 {
88            return Some(i);
89        }
90    }
91    let count = end - start + 1;
92    if count == 1 {
93        return Some(start);
94    }
95    if count >= 3 {
96        if ls[start].c == 'u'
97            && start + 2 <= end
98            && ls[start + 1].c == 'y'
99            && ls[start + 2].c == 'e'
100        {
101            return Some(start + 2);
102        }
103        return Some(start + 1);
104    }
105    if end + 1 < ls.len() {
106        return Some(end);
107    }
108    let (c1, c2) = (ls[start].c, ls[end].c);
109    if (c1 == 'o' && c2 == 'a') || (c1 == 'o' && c2 == 'e') || (c1 == 'u' && c2 == 'y') {
110        return Some(end); // always 2nd vowel — use typing order for òa (ofa) vs oà (oaf)
111    }
112    Some(start) // default open diphthong: tone on first vowel
113}
114
115fn telex_tone(c: char) -> Option<u8> {
116    match c.to_ascii_lowercase() {
117        's' => Some(2),
118        'f' => Some(1),
119        'r' => Some(3),
120        'x' => Some(4),
121        'j' => Some(5),
122        _ => None,
123    }
124}
125fn vni_tone(c: char) -> Option<u8> {
126    match c {
127        '1' => Some(2),
128        '2' => Some(1),
129        '3' => Some(3),
130        '4' => Some(4),
131        '5' => Some(5),
132        _ => None,
133    }
134}
135
136pub fn convert_telex(input: &str, modern: bool, short_w: bool) -> String {
137    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
138    for ch in input.chars() {
139        let lc = ch.to_ascii_lowercase();
140        let upper = ch.is_ascii_uppercase();
141        let n = ls.len();
142        if lc == 'd' && n > 0 && ls[n - 1].c == 'd' && !ls[n - 1].is_vowel && !ls[n - 1].is_dstroke
143        {
144            ls[n - 1].is_dstroke = true;
145            if upper {
146                ls[n - 1].upper = true;
147            }
148            continue;
149        }
150        // Double-vowel circumflex: search backwards through vowel cluster.
151        // Toggle behavior (Unikey): oo→ô, ooo→oo (3rd press undoes circumflex)
152        // Double-vowel circumflex: search backwards through vowel cluster.
153        // Toggle behavior (Unikey): oo→ô, ooo→oo (3rd press undoes circumflex)
154        if tables::is_vowel(lc) && matches!(lc, 'a' | 'e' | 'o') && n > 0 {
155            let mut consumed = false;
156            if let Some(vi) = last_vowel_index(&ls) {
157                for i in (0..=vi).rev() {
158                    if ls[i].is_vowel {
159                        if ls[i].c == lc {
160                            if ls[i].variant == 0 {
161                                ls[i].variant = 1;
162                                if upper {
163                                    ls[i].upper = true;
164                                }
165                                consumed = true;
166                                break;
167                            }
168                            if ls[i].variant == 1 {
169                                ls[i].variant = 0;
170                                break;
171                            } // toggle off, then push copy
172                        }
173                    } else {
174                        break;
175                    }
176                }
177            }
178            if consumed {
179                continue;
180            }
181        }
182        if lc == 'w' {
183            if let Some(vi) = last_w_target_index(&ls) {
184                match ls[vi].c {
185                    'a' => {
186                        ls[vi].variant = if ls[vi].variant == 2 { 0 } else { 2 };
187                        continue;
188                    }
189                    'o' => {
190                        if ls[vi].variant == 2 {
191                            ls[vi].variant = 0;
192                            if vi > 0 && ls[vi - 1].c == 'u' && ls[vi - 1].variant == 2 {
193                                ls[vi - 1].variant = 0;
194                            }
195                        } else {
196                            ls[vi].variant = 2;
197                            if vi > 0 && ls[vi - 1].c == 'u' {
198                                ls[vi - 1].variant = 2;
199                            }
200                        }
201                        continue;
202                    }
203                    'u' => {
204                        if ls[vi].variant == 2 {
205                            if ls[vi].from_w {
206                                // standalone w toggle-off: ư → w
207                                ls[vi].c = 'w';
208                                ls[vi].is_vowel = false;
209                                ls[vi].from_w = false;
210                            }
211                            ls[vi].variant = 0;
212                        } else {
213                            ls[vi].variant = 2;
214                        }
215                        continue;
216                    }
217                    'e' => {
218                        ls[vi].variant = if ls[vi].variant == 1 { 0 } else { 1 };
219                        continue;
220                    }
221                    _ => {}
222                }
223            } else if short_w && !ls.last().map_or(false, |lt| lt.c == 'w' && !lt.is_vowel) {
224                // short_w mode: standalone w → ư (but not after a literal w)
225                ls.push(Letter {
226                    c: 'u',
227                    is_vowel: true,
228                    variant: 2,
229                    tone: 0,
230                    is_dstroke: false,
231                    upper,
232                    from_w: true,
233                });
234                continue;
235            }
236        }
237        if let Some(t) = telex_tone(lc) {
238            if let Some(vi) = tone_vowel_index(&ls, modern) {
239                ls[vi].tone = t;
240                continue; // replace existing tone
241            }
242        }
243        if lc == 'z' {
244            if let Some(vi) = tone_vowel_index(&ls, modern) {
245                if ls[vi].tone != 0 {
246                    ls[vi].tone = 0;
247                    continue;
248                }
249            }
250        }
251        ls.push(Letter::new(ch, upper));
252    }
253    emit(&ls)
254}
255
256/// Combined Telex + VNI mode.
257/// Accepts BOTH Telex and VNI keystrokes in one stream:
258///   - VNI digits (0-9) apply VNI tone/marks to the last vowel
259///   - Telex markers (w, s/f/r/x/j/z, and double-vowel ^/breve) apply Telex
260///   - everything else is a normal letter
261pub fn convert_teip_vni(input: &str, modern: bool, short_w: bool) -> String {
262    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
263    for ch in input.chars() {
264        let lc = ch.to_ascii_lowercase();
265        let upper = ch.is_ascii_uppercase();
266        let n = ls.len();
267
268        // ── VNI digit rules ──
269        if let Some(t) = vni_tone(lc) {
270            if let Some(vi) = tone_vowel_index(&ls, modern) {
271                ls[vi].tone = t;
272                continue;
273            }
274        }
275        if lc >= '0' && lc <= '9' {
276            match lc {
277                '6' => {
278                    if let Some(vi) = last_vowel_index(&ls) {
279                        if matches!(ls[vi].c, 'a' | 'e' | 'o') {
280                            ls[vi].variant = 1;
281                            continue;
282                        }
283                    }
284                }
285                '7' => {
286                    if let Some(vi) = last_vowel_index(&ls) {
287                        if matches!(ls[vi].c, 'o' | 'u') {
288                            ls[vi].variant = 2;
289                            if ls[vi].c == 'o' && vi > 0 && ls[vi - 1].c == 'u' {
290                                ls[vi - 1].variant = 2;
291                            }
292                            continue;
293                        }
294                    }
295                }
296                '8' => {
297                    if let Some(vi) = last_vowel_index(&ls) {
298                        if ls[vi].c == 'a' {
299                            ls[vi].variant = 2;
300                            continue;
301                        }
302                    }
303                }
304                '9' => {
305                    if let Some(di) = last_d_index(&ls) {
306                        ls[di].is_dstroke = true;
307                        if upper {
308                            ls[di].upper = true;
309                        }
310                    }
311                    continue;
312                }
313                '0' => {
314                    continue;
315                }
316                _ => {} // digits 1-5 already handled by vni_tone above
317            }
318        }
319
320        // ── Telex dd → đ ──
321        if lc == 'd' && n > 0 && ls[n - 1].c == 'd' && !ls[n - 1].is_vowel && !ls[n - 1].is_dstroke
322        {
323            ls[n - 1].is_dstroke = true;
324            if upper {
325                ls[n - 1].upper = true;
326            }
327            continue;
328        }
329
330        // ── Telex circumflex: double same vowel ──
331        // Search backwards from last vowel through vowel cluster.
332        // Toggle behavior (Unikey): oo→ô, ooo→oo (3rd press undoes circumflex)
333        if tables::is_vowel(lc) && matches!(lc, 'a' | 'e' | 'o') && n > 0 {
334            let mut consumed = false;
335            if let Some(vi) = last_vowel_index(&ls) {
336                for i in (0..=vi).rev() {
337                    if ls[i].is_vowel {
338                        if ls[i].c == lc {
339                            if ls[i].variant == 0 {
340                                ls[i].variant = 1;
341                                if upper {
342                                    ls[i].upper = true;
343                                }
344                                consumed = true;
345                                break;
346                            }
347                            if ls[i].variant == 1 {
348                                ls[i].variant = 0;
349                                break;
350                            } // toggle off, then push copy
351                        }
352                    } else {
353                        break;
354                    }
355                }
356            }
357            if consumed {
358                continue;
359            }
360        }
361
362        // ── Telex w (breve/horn) ──
363        if lc == 'w' {
364            if let Some(vi) = last_w_target_index(&ls) {
365                match ls[vi].c {
366                    'a' => {
367                        ls[vi].variant = if ls[vi].variant == 2 { 0 } else { 2 };
368                        continue;
369                    }
370                    'o' => {
371                        if ls[vi].variant == 2 {
372                            ls[vi].variant = 0;
373                            if vi > 0 && ls[vi - 1].c == 'u' && ls[vi - 1].variant == 2 {
374                                ls[vi - 1].variant = 0;
375                            }
376                        } else {
377                            ls[vi].variant = 2;
378                            if vi > 0 && ls[vi - 1].c == 'u' {
379                                ls[vi - 1].variant = 2;
380                            }
381                        }
382                        continue;
383                    }
384                    'u' => {
385                        if ls[vi].variant == 2 {
386                            if ls[vi].from_w {
387                                ls[vi].c = 'w';
388                                ls[vi].is_vowel = false;
389                                ls[vi].from_w = false;
390                            }
391                            ls[vi].variant = 0;
392                        } else {
393                            ls[vi].variant = 2;
394                        }
395                        continue;
396                    }
397                    'e' => {
398                        ls[vi].variant = if ls[vi].variant == 1 { 0 } else { 1 };
399                        continue;
400                    }
401                    _ => {}
402                }
403            } else if short_w && !ls.last().map_or(false, |lt| lt.c == 'w' && !lt.is_vowel) {
404                ls.push(Letter {
405                    c: 'u',
406                    is_vowel: true,
407                    variant: 2,
408                    tone: 0,
409                    is_dstroke: false,
410                    upper,
411                    from_w: true,
412                });
413                continue;
414            }
415        }
416
417        // ── Telex tone marks ──
418        if let Some(t) = telex_tone(lc) {
419            if let Some(vi) = tone_vowel_index(&ls, modern) {
420                ls[vi].tone = t;
421                continue; // replace existing tone
422            }
423        }
424        if lc == 'z' {
425            if let Some(vi) = tone_vowel_index(&ls, modern) {
426                if ls[vi].tone != 0 {
427                    ls[vi].tone = 0;
428                    continue;
429                }
430            }
431        }
432
433        ls.push(Letter::new(ch, upper));
434    }
435    emit(&ls)
436}
437
438/// VIQR (Vietnamese Quoted-Readable) conversion.
439///
440/// Uses ASCII punctuation as diacritic markers:
441///   ^ circumflex, ( breve, + horn (modifiers)
442///   ' sac, ` huyen, ? hoi, ~ nga, . nang (tones)
443///   dd -> đ d-stroke
444pub fn convert_viqr(input: &str) -> String {
445    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
446    for ch in input.chars() {
447        let lc = ch.to_ascii_lowercase();
448        let n = ls.len();
449
450        // Modifier characters: apply to last vowel
451        match ch {
452            '^' | '(' | '+' => {
453                if let Some(vi) = last_vowel_index(&ls) {
454                    match (ch, ls[vi].c) {
455                        ('^', 'a' | 'e' | 'o') => {
456                            ls[vi].variant = 1;
457                            continue;
458                        }
459                        ('(', 'a') => {
460                            ls[vi].variant = 2;
461                            continue;
462                        }
463                        ('+', 'o' | 'u') => {
464                            ls[vi].variant = 2;
465                            continue;
466                        }
467                        _ => {} // illegal combination: fall through to literal
468                    }
469                }
470                // Fall through: treat as literal character
471                ls.push(Letter {
472                    c: ch,
473                    is_vowel: false,
474                    variant: 0,
475                    tone: 0,
476                    is_dstroke: false,
477                    upper: false,
478                    from_w: false,
479                });
480                continue;
481            }
482            _ => {}
483        }
484
485        // Tone characters
486        match ch {
487            '\'' => {
488                if let Some(vi) = last_vowel_index(&ls) {
489                    ls[vi].tone = 2;
490                    continue;
491                }
492            }
493            '`' => {
494                if let Some(vi) = last_vowel_index(&ls) {
495                    ls[vi].tone = 1;
496                    continue;
497                }
498            }
499            '?' => {
500                if let Some(vi) = last_vowel_index(&ls) {
501                    ls[vi].tone = 3;
502                    continue;
503                }
504            }
505            '~' => {
506                if let Some(vi) = last_vowel_index(&ls) {
507                    ls[vi].tone = 4;
508                    continue;
509                }
510            }
511            '.' => {
512                if let Some(vi) = last_vowel_index(&ls) {
513                    ls[vi].tone = 5;
514                    continue;
515                }
516            }
517            _ => {}
518        }
519
520        // dd → đ
521        if lc == 'd' && n > 0 && ls[n - 1].c == 'd' && !ls[n - 1].is_vowel && !ls[n - 1].is_dstroke
522        {
523            ls[n - 1].is_dstroke = true;
524            if ch.is_ascii_uppercase() {
525                ls[n - 1].upper = true;
526            }
527            continue;
528        }
529
530        ls.push(Letter::new(ch, ch.is_ascii_uppercase()));
531    }
532    emit(&ls)
533}
534
535pub fn convert_vni(input: &str, modern: bool, _short_w: bool) -> String {
536    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
537    for ch in input.chars() {
538        let lc = ch.to_ascii_lowercase();
539        let upper = ch.is_ascii_uppercase();
540        if let Some(t) = vni_tone(lc) {
541            if let Some(vi) = tone_vowel_index(&ls, modern) {
542                ls[vi].tone = t;
543                continue;
544            }
545        }
546        match lc {
547            '6' => {
548                if let Some(vi) = last_vowel_index(&ls) {
549                    if matches!(ls[vi].c, 'a' | 'e' | 'o') {
550                        ls[vi].variant = 1;
551                        continue;
552                    }
553                }
554            }
555            '7' => {
556                if let Some(vi) = last_vowel_index(&ls) {
557                    if matches!(ls[vi].c, 'o' | 'u') {
558                        ls[vi].variant = 2;
559                        if ls[vi].c == 'o' && vi > 0 && ls[vi - 1].c == 'u' {
560                            ls[vi - 1].variant = 2;
561                        }
562                        continue;
563                    }
564                }
565            }
566            '8' => {
567                if let Some(vi) = last_vowel_index(&ls) {
568                    if ls[vi].c == 'a' {
569                        ls[vi].variant = 2;
570                        continue;
571                    }
572                }
573            }
574            '9' => {
575                if let Some(di) = last_d_index(&ls) {
576                    ls[di].is_dstroke = true;
577                    if upper {
578                        ls[di].upper = true;
579                    }
580                }
581                continue;
582            }
583            '0' => {
584                continue;
585            }
586            _ => {}
587        }
588        if lc.is_ascii_alphabetic() || (lc.is_ascii_digit() && !ls.is_empty()) {
589            ls.push(Letter::new(ch, upper));
590        }
591    }
592    emit(&ls)
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    // modern=true, short_w=false (current default behavior)
599    fn t(s: &str) -> String {
600        convert_telex(s, true, false)
601    }
602    fn tw(s: &str) -> String {
603        convert_telex(s, true, true)
604    }
605    // traditional tone placement (short_w enabled)
606    fn tt(s: &str) -> String {
607        convert_telex(s, false, true)
608    }
609    fn vt(s: &str) -> String {
610        convert_vni(s, false, true)
611    }
612
613    #[test]
614    fn telex_basic() {
615        assert_eq!(t("as"), "á");
616        assert_eq!(t("af"), "à");
617        assert_eq!(t("ar"), "ả");
618        assert_eq!(t("ax"), "ã");
619        assert_eq!(t("aj"), "ạ");
620        assert_eq!(t("aw"), "ă");
621        assert_eq!(t("aa"), "â");
622        assert_eq!(t("ee"), "ê");
623        assert_eq!(t("oo"), "ô");
624        assert_eq!(t("ow"), "ơ");
625        assert_eq!(t("uw"), "ư");
626        assert_eq!(t("dd"), "đ");
627    }
628    #[test]
629    fn telex_compound() {
630        assert_eq!(t("aws"), "ắ");
631        assert_eq!(t("aas"), "ấ");
632        assert_eq!(t("ees"), "ế");
633        assert_eq!(t("oos"), "ố");
634        assert_eq!(t("ows"), "ớ");
635        assert_eq!(t("uws"), "ứ");
636    }
637    #[test]
638    fn telex_words() {
639        assert_eq!(t("xin"), "xin");
640        assert_eq!(t("tooi"), "tôi");
641        assert_eq!(t("laf"), "là");
642        assert_eq!(t("cos"), "có");
643        assert_eq!(t("khoong"), "không");
644        assert_eq!(t("tieengs"), "tiếng");
645        assert_eq!(t("Vieetj"), "Việt");
646    }
647    #[test]
648    fn telex_uow() {
649        assert_eq!(t("uow"), "ươ");
650        assert_eq!(t("cuowcs"), "cước");
651        assert_eq!(t("dduwowcj"), "được");
652        assert_eq!(t("nguwowif"), "người");
653    }
654    #[test]
655    fn telex_gi_qu() {
656        assert_eq!(t("gif"), "gì");
657        assert_eq!(t("giowf"), "giờ");
658        assert_eq!(t("gioir"), "giỏi");
659        assert_eq!(t("gias"), "giá");
660        assert_eq!(t("quas"), "quá");
661    }
662    #[test]
663    fn telex_tone_placement() {
664        assert_eq!(t("chafo"), "chào");
665        assert_eq!(t("toans"), "toán");
666        assert_eq!(t("hoaf"), "hoà");
667        assert_eq!(t("thowif"), "thời");
668        assert_eq!(t("cuar"), "của");
669        assert_eq!(t("thoix"), "thõi");
670        // òa/oà both accessible via typing order:
671        assert_eq!(t("ofa"), "òa"); // tone between vowels → on first vowel
672        assert_eq!(t("oaf"), "oà"); // tone after all vowels → on second vowel
673    }
674    #[test]
675    fn telex_english() {
676        assert_eq!(t("hello"), "hello");
677        assert_eq!(t("wood"), "wôd");
678        assert_eq!(t("reboot"), "rebôt");
679    }
680    #[test]
681    fn telex_z() {
682        assert_eq!(t("asz"), "a");
683    }
684    #[test]
685    fn telex_w_standalone() {
686        assert_eq!(t("w"), "w"); // without short_w: w stays as w
687        assert_eq!(tw("w"), "ư"); // with short_w: standalone w → ư
688        assert_eq!(tw("ws"), "ứ");
689        assert_eq!(tw("wf"), "ừ");
690        assert_eq!(tw("wn"), "ưn");
691    }
692
693    // ── traditional tone placement (modern=false, short_w=true) ─────────────
694
695    #[test]
696    fn telex_dd_traditional() {
697        assert_eq!(tt("dd"), "đ");
698        assert_eq!(tt("DD"), "Đ");
699        assert_eq!(tt("Dd"), "Đ");
700        assert_eq!(tt("dD"), "Đ");
701    }
702    #[test]
703    fn telex_marks_traditional() {
704        assert_eq!(tt("aa"), "â");
705        assert_eq!(tt("aw"), "ă");
706        assert_eq!(tt("ee"), "ê");
707        assert_eq!(tt("ew"), "ê");
708        assert_eq!(tt("oo"), "ô");
709        assert_eq!(tt("ow"), "ơ");
710        assert_eq!(tt("uw"), "ư");
711        assert_eq!(tt("uow"), "ươ");
712    }
713    #[test]
714    fn telex_w_traditional() {
715        assert_eq!(tt("w"), "ư");
716        assert_eq!(tt("W"), "Ư");
717        assert_eq!(tt("wn"), "ưn");
718        assert_eq!(tt("ws"), "ứ");
719        assert_eq!(tt("wf"), "ừ");
720    }
721    #[test]
722    fn telex_compound_traditional() {
723        assert_eq!(tt("aws"), "ắ");
724        assert_eq!(tt("aas"), "ấ");
725        assert_eq!(tt("ees"), "ế");
726        assert_eq!(tt("oos"), "ố");
727        assert_eq!(tt("ows"), "ớ");
728        assert_eq!(tt("uws"), "ứ");
729    }
730    #[test]
731    fn telex_words_traditional() {
732        assert_eq!(tt("chaof"), "chào");
733        assert_eq!(tt("vieejt"), "việt");
734        assert_eq!(tt("tieengs"), "tiếng");
735        assert_eq!(tt("quas"), "quá");
736        assert_eq!(tt("gias"), "giá");
737        assert_eq!(tt("xin"), "xin");
738        assert_eq!(tt("tooi"), "tôi");
739        assert_eq!(tt("laf"), "là");
740        assert_eq!(tt("cos"), "có");
741        assert_eq!(tt("khoong"), "không");
742        assert_eq!(tt("Vieetj"), "Việt");
743    }
744    #[test]
745    fn telex_tone_traditional() {
746        // traditional: tone on 2nd vowel for oa/oe/uy open diphthongs
747        assert_eq!(tt("toans"), "toán");
748        assert_eq!(tt("hoaf"), "hoà");
749        assert_eq!(tt("thowif"), "thời");
750        assert_eq!(tt("cuar"), "của");
751    }
752    #[test]
753    fn telex_gi_qu_traditional() {
754        assert_eq!(tt("gif"), "gì");
755        assert_eq!(tt("giowf"), "giờ");
756        assert_eq!(tt("gioir"), "giỏi");
757    }
758    #[test]
759    fn telex_uow_traditional() {
760        assert_eq!(tt("cuowcs"), "cước");
761        assert_eq!(tt("dduwowcj"), "được");
762        assert_eq!(tt("nguwowif"), "người");
763    }
764    #[test]
765    fn telex_z_traditional() {
766        assert_eq!(tt("asz"), "a");
767    }
768    #[test]
769    fn telex_english_traditional() {
770        assert_eq!(tt("hello"), "hello");
771    }
772    #[test]
773    fn telex_more_traditional() {
774        assert_eq!(tt("hoanf"), "hoàn");
775        assert_eq!(tt("huyeenf"), "huyền");
776        assert_eq!(tt("nguyeexn"), "nguyễn");
777        assert_eq!(tt("ngoaif"), "ngoài");
778        assert_eq!(tt("thuowngf"), "thường");
779    }
780    #[test]
781    fn vni_marks_traditional() {
782        assert_eq!(vt("d9"), "đ");
783        assert_eq!(vt("a6"), "â");
784        assert_eq!(vt("a8"), "ă");
785        assert_eq!(vt("e6"), "ê");
786        assert_eq!(vt("o6"), "ô");
787        assert_eq!(vt("o7"), "ơ");
788        assert_eq!(vt("u7"), "ư");
789        assert_eq!(vt("uo7"), "ươ");
790    }
791    #[test]
792    fn vni_words_traditional() {
793        assert_eq!(vt("vie65t"), "việt");
794        assert_eq!(vt("tie61ng"), "tiếng");
795        assert_eq!(vt("qua1"), "quá");
796        assert_eq!(vt("gia1"), "giá");
797    }
798
799    // ── Tone replacement tests ─────────────────────────────────────
800    #[test]
801    fn telex_tone_replace() {
802        assert_eq!(tt("saosf"), "sào"); // sáo + f → sào (acute→grave)
803        assert_eq!(tt("saojs"), "sáo"); // sạ + s → sáo (dot→acute)
804        assert_eq!(tt("saojr"), "sảo"); // sạo + r → sảo (dot→hook)
805        assert_eq!(tt("hoasf"), "hoà"); // hóa + f → hoà (acute→grave)
806        assert_eq!(tt("toansr"), "toản"); // toán + r → toản (acute→hook)
807    }
808    #[test]
809    fn teipvni_tone_replace() {
810        assert_eq!(tv("saosf"), "sào"); // Telex acute→grave in combined mode
811        assert_eq!(tv("sao1f"), "sào"); // VNI acute + Telex grave
812    }
813
814    // ── Unikey-style triple-press toggle ────────────────────────────
815    #[test]
816    fn telex_triple_toggle() {
817        assert_eq!(tt("rooot"), "root"); // oo→ô, ooo→oo
818        assert_eq!(tt("roo"), "rô");
819        assert_eq!(tt("aa"), "â"); // double → circumflex
820        assert_eq!(tt("aaa"), "aa"); // triple → undo
821        assert_eq!(tt("aaaa"), "aâ"); // quadruple → circumflex again
822        assert_eq!(tt("ee"), "ê");
823        assert_eq!(tt("eee"), "ee");
824        assert_eq!(tt("oooo"), "oô"); // oo→ô, ooo→oo, oooo→oô
825    }
826
827    // ── Unikey-style double-vowel across y ──────────────────────────
828    #[test]
829    fn telex_double_vowel_across_y() {
830        assert_eq!(tt("vaya"), "vây"); // vay + a → vây
831        assert_eq!(tt("vayaj"), "vậy"); // vay + a + j → vậy
832        assert_eq!(tt("vayas"), "vấy"); // vay + a + s → vấy
833        assert_eq!(tt("vayaf"), "vầy"); // vay + a + f → vầy
834        assert_eq!(tt("mayas"), "mấy"); // may + a + s → mấy
835        assert_eq!(tt("dayaj"), "dậy"); // day + a + j → dậy
836        assert_eq!(tt("quaya"), "quây"); // quay + a → quây
837        assert_eq!(tt("quayar"), "quẩy"); // quay + a + r → quẩy
838                                          // au + a → âu
839        assert_eq!(tt("saua"), "sâu");
840        assert_eq!(tt("sauas"), "sấu");
841        assert_eq!(tt("dauaf"), "dầu");
842        // ee still works (immediate neighbor)
843        assert_eq!(tt("ee"), "ê");
844        // aay still works
845        assert_eq!(tt("aay"), "ây");
846    }
847
848    // ── w-toggle: pressing w again undoes the mark ──────────────────
849    #[test]
850    fn telex_w_toggle() {
851        // Standalone w → ư, ww → w, www → ww, wwww → www
852        assert_eq!(tt("w"), "ư");
853        assert_eq!(tt("ww"), "w");
854        assert_eq!(tt("www"), "ww");
855        assert_eq!(tt("wwww"), "www");
856        // wws: toggle off to w, then s has no vowel → "ws"
857        assert_eq!(tt("wws"), "ws");
858    }
859    #[test]
860    fn telex_w_toggle_regular() {
861        assert_eq!(tt("aw"), "ă"); // a+w → ă
862        assert_eq!(tt("aww"), "a"); // ă+w toggle off → a
863        assert_eq!(tt("ow"), "ơ"); // o+w → ơ
864        assert_eq!(tt("oww"), "o"); // ơ+w toggle off → o
865        assert_eq!(tt("uw"), "ư"); // u+w → ư
866        assert_eq!(tt("uww"), "u"); // ư+w toggle off → u
867        assert_eq!(tt("ew"), "ê"); // e+w → ê
868        assert_eq!(tt("eww"), "e"); // ê+w toggle off → e
869        assert_eq!(tt("uow"), "ươ"); // u+o+w → ươ
870        assert_eq!(tt("uoww"), "uo"); // ươ+w toggle off → uo
871    }
872
873    // ── w-target tests: w applies to last modifiable vowel ──────────
874    #[test]
875    fn telex_w_target() {
876        // toi+w: w modifies 'o' (last modifiable), not 'i'
877        assert_eq!(tt("toiw"), "tơi");
878        assert_eq!(tt("toiws"), "tới"); // toi + w + sắc → tới
879        assert_eq!(tt("toiwf"), "tời"); // toi + w + huyền → tời
880        assert_eq!(tt("toiwr"), "tởi"); // toi + w + hỏi → tởi
881        assert_eq!(tt("toiwj"), "tợi"); // toi + w + nặng → tợi
882                                        // nguoi + w + f = người (w→ơ on o, uow→ươ, f→huyền on ư)
883        assert_eq!(tt("nguoiwf"), "người");
884        assert_eq!(tt("nguoiws"), "ngưới");
885        // muoi + w + f = mười
886        assert_eq!(tt("muoiwf"), "mười");
887        // tuoi + w + s = tưới
888        assert_eq!(tt("tuoiws"), "tưới");
889        // "tuổi" correctly typed as tuooir (oo→ô)
890        assert_eq!(tt("tuooir"), "tuổi");
891        assert_eq!(tt("tuooif"), "tuồi");
892    }
893    #[test]
894    fn telex_toan() {
895        // "toàn" has trailing consonant → tone on 2nd vowel always
896        assert_eq!(tt("toanf"), "toàn");
897        assert_eq!(t("toanf"), "toàn");
898        assert_eq!(tt("hoanf"), "hoàn");
899        assert_eq!(t("hoanf"), "hoàn");
900        // open diphthong oa/oe/uy: always 2nd vowel
901        // òa typed as ofa (tone between vowels), oà typed as oaf (tone after)
902        assert_eq!(t("oaf"), "oà"); // tone after all vowels → 2nd
903        assert_eq!(t("ofa"), "òa"); // tone between vowels → 1st
904        assert_eq!(tt("oaf"), "oà");
905        assert_eq!(tt("ofa"), "òa"); // same
906    }
907
908    // ── TeipVni (combined Telex + VNI) tests ─────────────────────────
909    fn tv(s: &str) -> String {
910        convert_teip_vni(s, false, true)
911    }
912
913    #[test]
914    fn teipvni_telex_words() {
915        assert_eq!(tv("vieejt"), "việt");
916        assert_eq!(tv("dd"), "đ");
917        assert_eq!(tv("aa"), "â");
918        assert_eq!(tv("aw"), "ă");
919        assert_eq!(tv("ee"), "ê");
920        assert_eq!(tv("oo"), "ô");
921        assert_eq!(tv("ow"), "ơ");
922        assert_eq!(tv("uw"), "ư");
923        assert_eq!(tv("uow"), "ươ");
924        assert_eq!(tv("w"), "ư");
925    }
926    #[test]
927    fn teipvni_vni_marks() {
928        assert_eq!(tv("a6"), "â");
929        assert_eq!(tv("a8"), "ă");
930        assert_eq!(tv("e6"), "ê");
931        assert_eq!(tv("o6"), "ô");
932        assert_eq!(tv("o7"), "ơ");
933        assert_eq!(tv("u7"), "ư");
934        assert_eq!(tv("uo7"), "ươ");
935        assert_eq!(tv("d9"), "đ");
936    }
937    #[test]
938    fn teipvni_numbers() {
939        // VNI tones
940        assert_eq!(tv("qua1"), "quá");
941        assert_eq!(tv("gia1"), "giá");
942        assert_eq!(tv("vie65t"), "việt");
943        assert_eq!(tv("tie61ng"), "tiếng");
944    }
945    #[test]
946    fn teipvni_mixed() {
947        // Mix Telex and VNI in same stream
948        assert_eq!(tv("vie6t"), "viêt"); // VNI circumflex + Telex
949        assert_eq!(tv("vie6ts"), "viết"); // VNI circumflex + Telex tone
950        assert_eq!(tv("tie6ngs"), "tiếng");
951    }
952
953    // ── VIQR tests ──────────────────────────────────────────────────
954    fn viqr(s: &str) -> String {
955        convert_viqr(s)
956    }
957
958    #[test]
959    fn viqr_marks() {
960        assert_eq!(viqr("a^"), "â");
961        assert_eq!(viqr("a("), "ă");
962        assert_eq!(viqr("e^"), "ê");
963        assert_eq!(viqr("o^"), "ô");
964        assert_eq!(viqr("o+"), "ơ");
965        assert_eq!(viqr("u+"), "ư");
966        assert_eq!(viqr("dd"), "đ");
967    }
968    #[test]
969    fn viqr_tones() {
970        assert_eq!(viqr("a'"), "á");
971        assert_eq!(viqr("a`"), "à");
972        assert_eq!(viqr("a?"), "ả");
973        assert_eq!(viqr("a~"), "ã");
974        assert_eq!(viqr("a."), "ạ");
975    }
976    #[test]
977    fn viqr_words() {
978        assert_eq!(viqr("vie^'t"), "viết");
979        assert_eq!(viqr("nu+o+'c"), "nước");
980        assert_eq!(viqr("ngu+o+`i"), "người");
981    }
982    #[test]
983    fn viqr_dd() {
984        assert_eq!(viqr("dd"), "đ");
985        assert_eq!(viqr("DD"), "Đ");
986    }
987    #[test]
988    fn viqr_english() {
989        assert_eq!(viqr("hello"), "hello");
990    }
991}
992
993#[test]
994fn full_parity() {
995    // This test verifies full parity for all 73 reference cases.
996    // traditional settings: modern=false (traditional tone placement), short_w=true
997
998    struct Case {
999        input: &'static str,
1000        expected: &'static str,
1001        method: u8,
1002    }
1003    // method: 1=Telex, 2=VNI, 3=VIQR, 4=TeipVni
1004    let cases = [
1005        // ── Telex ──
1006        Case {
1007            input: "dd",
1008            expected: "đ",
1009            method: 1,
1010        },
1011        Case {
1012            input: "DD",
1013            expected: "Đ",
1014            method: 1,
1015        },
1016        Case {
1017            input: "Dd",
1018            expected: "Đ",
1019            method: 1,
1020        },
1021        Case {
1022            input: "dD",
1023            expected: "Đ",
1024            method: 1,
1025        },
1026        Case {
1027            input: "aa",
1028            expected: "â",
1029            method: 1,
1030        },
1031        Case {
1032            input: "aw",
1033            expected: "ă",
1034            method: 1,
1035        },
1036        Case {
1037            input: "ee",
1038            expected: "ê",
1039            method: 1,
1040        },
1041        Case {
1042            input: "ew",
1043            expected: "ê",
1044            method: 1,
1045        },
1046        Case {
1047            input: "oo",
1048            expected: "ô",
1049            method: 1,
1050        },
1051        Case {
1052            input: "ow",
1053            expected: "ơ",
1054            method: 1,
1055        },
1056        Case {
1057            input: "uw",
1058            expected: "ư",
1059            method: 1,
1060        },
1061        Case {
1062            input: "uow",
1063            expected: "ươ",
1064            method: 1,
1065        },
1066        Case {
1067            input: "w",
1068            expected: "ư",
1069            method: 1,
1070        },
1071        Case {
1072            input: "W",
1073            expected: "Ư",
1074            method: 1,
1075        },
1076        Case {
1077            input: "wn",
1078            expected: "ưn",
1079            method: 1,
1080        },
1081        Case {
1082            input: "ws",
1083            expected: "ứ",
1084            method: 1,
1085        },
1086        Case {
1087            input: "wf",
1088            expected: "ừ",
1089            method: 1,
1090        },
1091        Case {
1092            input: "aws",
1093            expected: "ắ",
1094            method: 1,
1095        },
1096        Case {
1097            input: "aas",
1098            expected: "ấ",
1099            method: 1,
1100        },
1101        Case {
1102            input: "ees",
1103            expected: "ế",
1104            method: 1,
1105        },
1106        Case {
1107            input: "oos",
1108            expected: "ố",
1109            method: 1,
1110        },
1111        Case {
1112            input: "ows",
1113            expected: "ớ",
1114            method: 1,
1115        },
1116        Case {
1117            input: "uws",
1118            expected: "ứ",
1119            method: 1,
1120        },
1121        Case {
1122            input: "chaof",
1123            expected: "chào",
1124            method: 1,
1125        },
1126        Case {
1127            input: "vieejt",
1128            expected: "việt",
1129            method: 1,
1130        },
1131        Case {
1132            input: "tieengs",
1133            expected: "tiếng",
1134            method: 1,
1135        },
1136        Case {
1137            input: "quas",
1138            expected: "quá",
1139            method: 1,
1140        },
1141        Case {
1142            input: "gias",
1143            expected: "giá",
1144            method: 1,
1145        },
1146        Case {
1147            input: "xin",
1148            expected: "xin",
1149            method: 1,
1150        },
1151        Case {
1152            input: "tooi",
1153            expected: "tôi",
1154            method: 1,
1155        },
1156        Case {
1157            input: "laf",
1158            expected: "là",
1159            method: 1,
1160        },
1161        Case {
1162            input: "cos",
1163            expected: "có",
1164            method: 1,
1165        },
1166        Case {
1167            input: "khoong",
1168            expected: "không",
1169            method: 1,
1170        },
1171        Case {
1172            input: "Vieetj",
1173            expected: "Việt",
1174            method: 1,
1175        },
1176        Case {
1177            input: "toans",
1178            expected: "toán",
1179            method: 1,
1180        },
1181        Case {
1182            input: "hoaf",
1183            expected: "hoà",
1184            method: 1,
1185        },
1186        Case {
1187            input: "thowif",
1188            expected: "thời",
1189            method: 1,
1190        },
1191        Case {
1192            input: "cuar",
1193            expected: "của",
1194            method: 1,
1195        },
1196        Case {
1197            input: "gif",
1198            expected: "gì",
1199            method: 1,
1200        },
1201        Case {
1202            input: "giowf",
1203            expected: "giờ",
1204            method: 1,
1205        },
1206        Case {
1207            input: "gioir",
1208            expected: "giỏi",
1209            method: 1,
1210        },
1211        Case {
1212            input: "cuowcs",
1213            expected: "cước",
1214            method: 1,
1215        },
1216        Case {
1217            input: "dduwowcj",
1218            expected: "được",
1219            method: 1,
1220        },
1221        Case {
1222            input: "nguwowif",
1223            expected: "người",
1224            method: 1,
1225        },
1226        Case {
1227            input: "asz",
1228            expected: "a",
1229            method: 1,
1230        },
1231        Case {
1232            input: "hello",
1233            expected: "hello",
1234            method: 1,
1235        },
1236        Case {
1237            input: "hoanf",
1238            expected: "hoàn",
1239            method: 1,
1240        },
1241        Case {
1242            input: "huyeenf",
1243            expected: "huyền",
1244            method: 1,
1245        },
1246        Case {
1247            input: "nguyeexn",
1248            expected: "nguyễn",
1249            method: 1,
1250        },
1251        Case {
1252            input: "ngoaif",
1253            expected: "ngoài",
1254            method: 1,
1255        },
1256        Case {
1257            input: "thuowngf",
1258            expected: "thường",
1259            method: 1,
1260        },
1261        // ── VNI ──
1262        Case {
1263            input: "d9",
1264            expected: "đ",
1265            method: 2,
1266        },
1267        Case {
1268            input: "a6",
1269            expected: "â",
1270            method: 2,
1271        },
1272        Case {
1273            input: "a8",
1274            expected: "ă",
1275            method: 2,
1276        },
1277        Case {
1278            input: "e6",
1279            expected: "ê",
1280            method: 2,
1281        },
1282        Case {
1283            input: "o6",
1284            expected: "ô",
1285            method: 2,
1286        },
1287        Case {
1288            input: "o7",
1289            expected: "ơ",
1290            method: 2,
1291        },
1292        Case {
1293            input: "u7",
1294            expected: "ư",
1295            method: 2,
1296        },
1297        Case {
1298            input: "uo7",
1299            expected: "ươ",
1300            method: 2,
1301        },
1302        Case {
1303            input: "vie65t",
1304            expected: "việt",
1305            method: 2,
1306        },
1307        Case {
1308            input: "tie61ng",
1309            expected: "tiếng",
1310            method: 2,
1311        },
1312        Case {
1313            input: "qua1",
1314            expected: "quá",
1315            method: 2,
1316        },
1317        Case {
1318            input: "gia1",
1319            expected: "giá",
1320            method: 2,
1321        },
1322        // ── TeipVni ──
1323        Case {
1324            input: "vie61t",
1325            expected: "viết",
1326            method: 4,
1327        },
1328        Case {
1329            input: "vieejt",
1330            expected: "việt",
1331            method: 4,
1332        },
1333        Case {
1334            input: "tie61ng",
1335            expected: "tiếng",
1336            method: 4,
1337        },
1338        Case {
1339            input: "a6",
1340            expected: "â",
1341            method: 4,
1342        },
1343        Case {
1344            input: "dd",
1345            expected: "đ",
1346            method: 4,
1347        },
1348        // ── VIQR ──
1349        Case {
1350            input: "dd",
1351            expected: "đ",
1352            method: 3,
1353        },
1354        Case {
1355            input: "DD",
1356            expected: "Đ",
1357            method: 3,
1358        },
1359        Case {
1360            input: "vie^'t",
1361            expected: "viết",
1362            method: 3,
1363        },
1364        Case {
1365            input: "nu+o+'c",
1366            expected: "nước",
1367            method: 3,
1368        },
1369        Case {
1370            input: "ngu+o+`i",
1371            expected: "người",
1372            method: 3,
1373        },
1374    ];
1375
1376    let mut failures = Vec::new();
1377    for c in &cases {
1378        let result = match c.method {
1379            1 => convert_telex(c.input, false, true),
1380            2 => convert_vni(c.input, false, true),
1381            3 => convert_viqr(c.input),
1382            4 => convert_teip_vni(c.input, false, true),
1383            _ => unreachable!(),
1384        };
1385        if result != c.expected {
1386            failures.push(format!(
1387                "[m{}] '{}' -> '{}' (expected '{}')",
1388                c.method, c.input, result, c.expected
1389            ));
1390        }
1391    }
1392    assert!(
1393        failures.is_empty(),
1394        "{} full parity failures:\n{}",
1395        failures.len(),
1396        failures.join("\n")
1397    );
1398}
1399
1400#[test]
1401fn comprehensive_vietnamese() {
1402    // Comprehensive test covering all Vietnamese syllable patterns.
1403    // Uses traditional settings: modern=false, short_w=true.
1404    // method: 1=Telex, 2=VNI, 3=VIQR, 4=TeipVni.
1405    // Method 0 = Telex (traditional) for brevity.
1406
1407    let cases: &[(&str, &str, u8)] = &[
1408        // ═══ SINGLE VOWELS — 5 tones each ═══
1409        ("as", "á", 1),
1410        ("af", "à", 1),
1411        ("ar", "ả", 1),
1412        ("ax", "ã", 1),
1413        ("aj", "ạ", 1),
1414        ("es", "é", 1),
1415        ("ef", "è", 1),
1416        ("er", "ẻ", 1),
1417        ("ex", "ẽ", 1),
1418        ("ej", "ẹ", 1),
1419        ("is", "í", 1),
1420        ("if", "ì", 1),
1421        ("ir", "ỉ", 1),
1422        ("ix", "ĩ", 1),
1423        ("ij", "ị", 1),
1424        ("os", "ó", 1),
1425        ("of", "ò", 1),
1426        ("or", "ỏ", 1),
1427        ("ox", "õ", 1),
1428        ("oj", "ọ", 1),
1429        ("us", "ú", 1),
1430        ("uf", "ù", 1),
1431        ("ur", "ủ", 1),
1432        ("ux", "ũ", 1),
1433        ("uj", "ụ", 1),
1434        ("ys", "ý", 1),
1435        ("yf", "ỳ", 1),
1436        ("yr", "ỷ", 1),
1437        ("yx", "ỹ", 1),
1438        ("yj", "ỵ", 1),
1439        // ═══ MARKED VOWELS (breve/circumflex/horn) + tones ═══
1440        // ă = aw
1441        ("aw", "ă", 1),
1442        ("aws", "ắ", 1),
1443        ("awf", "ằ", 1),
1444        ("awr", "ẳ", 1),
1445        ("awx", "ẵ", 1),
1446        ("awj", "ặ", 1),
1447        // â = aa
1448        ("aa", "â", 1),
1449        ("aas", "ấ", 1),
1450        ("aaf", "ầ", 1),
1451        ("aar", "ẩ", 1),
1452        ("aax", "ẫ", 1),
1453        ("aaj", "ậ", 1),
1454        // ê = ee / ew
1455        ("ee", "ê", 1),
1456        ("ees", "ế", 1),
1457        ("eef", "ề", 1),
1458        ("eer", "ể", 1),
1459        ("eex", "ễ", 1),
1460        ("eej", "ệ", 1),
1461        ("ew", "ê", 1),
1462        ("ews", "ế", 1),
1463        ("ewf", "ề", 1),
1464        ("ewr", "ể", 1),
1465        // ô = oo
1466        ("oo", "ô", 1),
1467        ("oos", "ố", 1),
1468        ("oof", "ồ", 1),
1469        ("oor", "ổ", 1),
1470        ("oox", "ỗ", 1),
1471        ("ooj", "ộ", 1),
1472        // ơ = ow
1473        ("ow", "ơ", 1),
1474        ("ows", "ớ", 1),
1475        ("owf", "ờ", 1),
1476        ("owr", "ở", 1),
1477        ("owx", "ỡ", 1),
1478        ("owj", "ợ", 1),
1479        // ư = uw
1480        ("uw", "ư", 1),
1481        ("uws", "ứ", 1),
1482        ("uwf", "ừ", 1),
1483        ("uwr", "ử", 1),
1484        ("uwx", "ữ", 1),
1485        ("uwj", "ự", 1),
1486        // ═══ dd → đ ═══
1487        ("dd", "đ", 1),
1488        // ═══ STANDALONE w → ư ═══
1489        ("w", "ư", 1),
1490        ("ws", "ứ", 1),
1491        ("wf", "ừ", 1),
1492        ("wr", "ử", 1),
1493        ("wx", "ữ", 1),
1494        ("wj", "ự", 1),
1495        ("wn", "ưn", 1),
1496        ("wt", "ưt", 1),
1497        // ═══ DIPHTHONGS — closed (trailing consonant) ═══
1498        // ai, ao, au
1499        ("ais", "ái", 1),
1500        ("aif", "ài", 1),
1501        ("air", "ải", 1),
1502        ("aix", "ãi", 1),
1503        ("aij", "ại", 1),
1504        ("aos", "áo", 1),
1505        ("aof", "ào", 1),
1506        ("aor", "ảo", 1),
1507        ("aox", "ão", 1),
1508        ("aoj", "ạo", 1),
1509        ("aus", "áu", 1),
1510        ("auf", "àu", 1),
1511        ("aur", "ảu", 1),
1512        ("aux", "ãu", 1),
1513        ("auj", "ạu", 1),
1514        // ây, ay
1515        ("aays", "ấy", 1),
1516        ("aayf", "ầy", 1),
1517        ("aayr", "ẩy", 1),
1518        ("aayx", "ẫy", 1),
1519        ("aayj", "ậy", 1),
1520        ("ays", "áy", 1),
1521        ("ayf", "ày", 1),
1522        ("ayr", "ảy", 1),
1523        ("ayx", "ãy", 1),
1524        ("ayj", "ạy", 1),
1525        // eo, êu
1526        ("eos", "éo", 1),
1527        ("eof", "èo", 1),
1528        ("eor", "ẻo", 1),
1529        ("eox", "ẽo", 1),
1530        ("eoj", "ẹo", 1),
1531        ("eeus", "ếu", 1),
1532        ("eeuf", "ều", 1),
1533        ("eeur", "ểu", 1),
1534        ("eeux", "ễu", 1),
1535        ("eeuj", "ệu", 1),
1536        // oi, ôi, ơi, ui, ưi, ưu
1537        ("ois", "ói", 1),
1538        ("oif", "òi", 1),
1539        ("oir", "ỏi", 1),
1540        ("oix", "õi", 1),
1541        ("oij", "ọi", 1),
1542        ("oois", "ối", 1),
1543        ("ooif", "ồi", 1),
1544        ("ooir", "ổi", 1),
1545        ("ooix", "ỗi", 1),
1546        ("ooij", "ội", 1),
1547        ("owis", "ới", 1),
1548        ("owif", "ời", 1),
1549        ("owir", "ởi", 1),
1550        ("owix", "ỡi", 1),
1551        ("owij", "ợi", 1),
1552        ("uis", "úi", 1),
1553        ("uif", "ùi", 1),
1554        ("uir", "ủi", 1),
1555        ("uix", "ũi", 1),
1556        ("uij", "ụi", 1),
1557        ("uwis", "ứi", 1),
1558        ("uwif", "ừi", 1),
1559        ("uwir", "ửi", 1),
1560        ("uwix", "ữi", 1),
1561        ("uwij", "ựi", 1),
1562        ("uwus", "ứu", 1),
1563        ("uwuf", "ừu", 1),
1564        ("uwur", "ửu", 1),
1565        ("uwux", "ữu", 1),
1566        ("uwuj", "ựu", 1),
1567        // ═══ oa, oe, uy — closed (trailing consonant, tone on 2nd vowel) ═══
1568        ("oans", "oán", 1),
1569        ("oanf", "oàn", 1),
1570        ("oanr", "oản", 1),
1571        ("oanx", "oãn", 1),
1572        ("oanj", "oạn", 1),
1573        ("oats", "oát", 1),
1574        ("oatf", "oàt", 1),
1575        ("oatr", "oảt", 1),
1576        ("oens", "oén", 1),
1577        ("oenf", "oèn", 1),
1578        ("uyns", "uýn", 1),
1579        ("uynf", "uỳn", 1),
1580        ("uynr", "uỷn", 1),
1581        // ═══ oa, oe, uy — open (traditional: tone on 2nd vowel) ═══
1582        ("oas", "oá", 1),
1583        ("oaf", "oà", 1),
1584        ("oar", "oả", 1),
1585        ("oax", "oã", 1),
1586        ("oaj", "oạ", 1),
1587        ("oes", "oé", 1),
1588        ("oef", "oè", 1),
1589        ("uys", "uý", 1),
1590        ("uyf", "uỳ", 1),
1591        ("uyr", "uỷ", 1),
1592        ("uyx", "uỹ", 1),
1593        ("uyj", "uỵ", 1),
1594        // ═══ TRIPHTHONGS ═══
1595        // oai, oay (tone on middle vowel)
1596        ("oais", "oái", 1),
1597        ("oaif", "oài", 1),
1598        ("oair", "oải", 1),
1599        ("oaix", "oãi", 1),
1600        ("oaij", "oại", 1),
1601        ("oays", "oáy", 1),
1602        ("oayf", "oày", 1),
1603        ("oayr", "oảy", 1),
1604        ("oayx", "oãy", 1),
1605        ("oayj", "oạy", 1),
1606        // uây (tone on middle vowel â)
1607        ("uaays", "uấy", 1),
1608        ("uaayf", "uầy", 1),
1609        ("uaayr", "uẩy", 1),
1610        ("uaayx", "uẫy", 1),
1611        ("uaayj", "uậy", 1),
1612        // uyê (tone on ê — 3rd vowel, the one with variant)
1613        ("uyeef", "uyề", 1),
1614        ("uyees", "uyế", 1),
1615        ("uyeer", "uyể", 1),
1616        ("uyeex", "uyễ", 1),
1617        ("uyeej", "uyệ", 1),
1618        // iê, yê
1619        ("ieef", "iề", 1),
1620        ("iees", "iế", 1),
1621        ("ieer", "iể", 1),
1622        ("ieex", "iễ", 1),
1623        ("ieej", "iệ", 1),
1624        ("yeef", "yề", 1),
1625        ("yees", "yế", 1),
1626        ("yeer", "yể", 1),
1627        ("yeex", "yễ", 1),
1628        ("yeej", "yệ", 1),
1629        // uôi = u + ô + i (oo→ô, tone on ô which has variant≠0)
1630        ("uoois", "uối", 1),
1631        ("uooif", "uồi", 1),
1632        ("uooir", "uổi", 1),
1633        ("uooix", "uỗi", 1),
1634        ("uooij", "uội", 1),
1635        // ươi = u + ơ + i (w targets o, uow spread → ươ, tone on ơ which has variant≠0)
1636        ("uoiws", "ưới", 1),
1637        ("uoiwf", "ười", 1),
1638        ("uoiwr", "ưởi", 1),
1639        ("uoiwx", "ưỡi", 1),
1640        ("uoiwj", "ượi", 1),
1641        // ═══ GI / QU initial consonant digraphs ═══
1642        ("gif", "gì", 1),
1643        ("gis", "gí", 1),
1644        ("gir", "gỉ", 1),
1645        ("gix", "gĩ", 1),
1646        ("gij", "gị", 1),
1647        ("gias", "giá", 1),
1648        ("giaf", "già", 1),
1649        ("giar", "giả", 1),
1650        ("giax", "giã", 1),
1651        ("giaj", "giạ", 1),
1652        ("gioir", "giỏi", 1),
1653        ("gioif", "giòi", 1),
1654        ("giois", "giói", 1),
1655        ("quas", "quá", 1),
1656        ("quaf", "quà", 1),
1657        ("quar", "quả", 1),
1658        ("quax", "quã", 1),
1659        ("quaj", "quạ", 1),
1660        ("quys", "quý", 1),
1661        ("quyf", "quỳ", 1),
1662        ("quyr", "quỷ", 1),
1663        ("quyx", "quỹ", 1),
1664        ("quyj", "quỵ", 1),
1665        // ═══ REAL WORDS ═══
1666        ("chaof", "chào", 1),
1667        ("vieejt", "việt", 1),
1668        ("tieengs", "tiếng", 1),
1669        ("khoong", "không", 1),
1670        ("cuar", "của", 1),
1671        ("cuowcs", "cước", 1),
1672        ("dduwowcj", "được", 1),
1673        ("nguwowif", "người", 1),
1674        ("thuowngf", "thường", 1),
1675        ("hoanf", "hoàn", 1),
1676        ("huyeenf", "huyền", 1),
1677        ("nguyeexn", "nguyễn", 1),
1678        ("ngoaif", "ngoài", 1),
1679        ("ngoafi", "ngoài", 1),
1680        ("vaya", "vây", 1),
1681        ("vayaj", "vậy", 1),
1682        ("vayas", "vấy", 1),
1683        ("mayas", "mấy", 1),
1684        // Double-vowel across consonant: nhan+a→nhân, khan+a→khân
1685        ("nhana", "nhân", 1),
1686        ("khanas", "khấn", 1),
1687        ("nhanaa", "nhana", 1),
1688        ("toans", "toán", 1),
1689        ("thowif", "thời", 1),
1690        ("xin", "xin", 1),
1691        ("tooi", "tôi", 1),
1692        ("laf", "là", 1),
1693        ("cos", "có", 1),
1694        ("banj", "bạn", 1),
1695        ("minhf", "mình", 1),
1696        ("nawm", "năm", 1),
1697        ("thangs", "tháng", 1),
1698        ("truwowngf", "trường", 1),
1699        ("luaatj", "luật", 1),
1700        // ═══ CONSONANT CLUSTERS ═══
1701        ("nhas", "nhá", 1),
1702        ("nghir", "nghỉ", 1),
1703        ("gheps", "ghép", 1),
1704        ("truws", "trứ", 1),
1705        ("thas", "thá", 1),
1706        ("phos", "phó", 1),
1707        ("khas", "khá", 1),
1708        ("chays", "cháy", 1),
1709        ("ngays", "ngáy", 1),
1710        // ═══ FINAL CONSONANT VARIETY ═══
1711        ("ddejp", "đẹp", 1),
1712        ("maats", "mất", 1),
1713        ("bacs", "bác", 1),
1714        ("ngachs", "ngách", 1),
1715        ("banhf", "bành", 1),
1716        ("tins", "tín", 1),
1717        ("metj", "mẹt", 1),
1718        // ═══ z (tone removal) ═══
1719        ("asz", "a", 1),
1720        ("awsjz", "ă", 1),
1721        ("aasz", "â", 1),
1722        // ═══ ENGLISH PASS-THROUGH (words without Telex tone/mark keys s/f/r/x/j/w/z) ═══
1723        ("hello", "hello", 1),
1724        ("bacon", "bacon", 1),
1725        ("thing", "thing", 1),
1726        ("climb", "climb", 1),
1727        // ═══ CAPITALIZATION ═══
1728        ("As", "Á", 1),
1729        ("Af", "À", 1),
1730        ("Aas", "Ấ", 1),
1731        ("Aws", "Ắ", 1),
1732        ("Dd", "Đ", 1),
1733        ("DD", "Đ", 1),
1734        ("W", "Ư", 1),
1735        ("Ws", "Ứ", 1),
1736        ("Vieetj", "Việt", 1),
1737        ("Tooi", "Tôi", 1),
1738        // ═══ VNI method ═══
1739        ("d9", "đ", 2),
1740        ("a6", "â", 2),
1741        ("a8", "ă", 2),
1742        ("e6", "ê", 2),
1743        ("o6", "ô", 2),
1744        ("o7", "ơ", 2),
1745        ("u7", "ư", 2),
1746        ("uo7", "ươ", 2),
1747        ("vie65t", "việt", 2),
1748        ("tie61ng", "tiếng", 2),
1749        ("qua1", "quá", 2),
1750        ("gia1", "giá", 2),
1751        // ═══ TeipVni combined ═══
1752        ("vie61t", "viết", 4),
1753        ("vieejt", "việt", 4),
1754        ("tie61ng", "tiếng", 4),
1755        ("a6", "â", 4),
1756        ("dd", "đ", 4),
1757        // ═══ VIQR method ═══
1758        ("dd", "đ", 3),
1759        ("DD", "Đ", 3),
1760        ("vie^'t", "viết", 3),
1761        ("nu+o+'c", "nước", 3),
1762        ("ngu+o+`i", "người", 3),
1763    ];
1764
1765    let mut failures = Vec::new();
1766    for &(input, expected, method) in cases {
1767        let result = match method {
1768            1 => convert_telex(input, false, true),
1769            2 => convert_vni(input, false, true),
1770            3 => convert_viqr(input),
1771            4 => convert_teip_vni(input, false, true),
1772            _ => unreachable!(),
1773        };
1774        if result != expected {
1775            failures.push(format!(
1776                "[m{}] '{}' -> '{}' (expected '{}')",
1777                method, input, result, expected
1778            ));
1779        }
1780    }
1781    assert!(
1782        failures.is_empty(),
1783        "{} comprehensive failures (traditional mode):\n{}",
1784        failures.len(),
1785        failures.join("\n")
1786    );
1787}