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)]
8pub(crate) struct Letter {
9    pub(crate) c: char,
10    pub(crate) is_vowel: bool,
11    pub(crate) variant: u8,
12    pub(crate) tone: u8,
13    pub(crate) is_dstroke: bool,
14    pub(crate) upper: bool,
15    pub(crate) from_w: bool,
16    pub(crate) horn_propagated: bool, // horn set by propagation (not explicit w)
17    pub(crate) circ_toggled: bool,    // circumflex was toggled off — don't reapply
18    pub(crate) d_toggled: bool,       // đ was toggled off — don't recreate
19    pub(crate) horn_toggled: bool,    // horn was toggled off — don't reapply
20}
21
22impl Letter {
23    pub(crate) fn new(c: char, upper: bool) -> Self {
24        let lc = c.to_ascii_lowercase();
25        Letter {
26            c: lc,
27            is_vowel: tables::is_vowel(lc),
28            variant: 0,
29            tone: 0,
30            is_dstroke: false,
31            upper,
32            from_w: false,
33            horn_propagated: false,
34            circ_toggled: false,
35            d_toggled: false,
36            horn_toggled: false,
37        }
38    }
39}
40
41fn emit(ls: &[Letter]) -> String {
42    let mut out = String::with_capacity(ls.len() * 3);
43    for lt in ls {
44        if lt.is_dstroke {
45            out.push_str(if lt.upper {
46                tables::D_STROKE_UP
47            } else {
48                tables::D_STROKE_LO
49            });
50        } else if lt.is_vowel {
51            out.push_str(tables::vowel_utf8(lt.c, lt.variant, lt.tone, lt.upper));
52        } else {
53            out.push(if lt.upper {
54                lt.c.to_ascii_uppercase()
55            } else {
56                lt.c
57            });
58        }
59    }
60    out
61}
62
63fn last_vowel_index(ls: &[Letter]) -> Option<usize> {
64    ls.iter().rposition(|lt| lt.is_vowel)
65}
66fn last_d_index(ls: &[Letter]) -> Option<usize> {
67    ls.iter()
68        .rposition(|lt| lt.c == 'd' && !lt.is_vowel && !lt.is_dstroke)
69}
70fn last_dstroke_index(ls: &[Letter]) -> Option<usize> {
71    ls.iter().rposition(|lt| lt.is_dstroke)
72}
73
74/// Find the last vowel that can be modified by `w`: a→ă, o→ơ, u→ư.
75/// `w` does NOT apply circumflex to `e` — that is done via double-vowel `ee`.
76///
77/// Special rule (matches x-unikey VSeqList): in a "u"+"a" diphthong, horn
78/// goes on `u` (→ ưa), not breve on `a` (→ uă).  For all other combinations
79fn tone_vowel_index(ls: &[Letter], modern: bool) -> Option<usize> {
80    let end = last_vowel_index(ls)?;
81    let mut start = end;
82    while start > 0 && ls[start - 1].is_vowel {
83        start -= 1;
84    }
85    if start < end {
86        if ls[start].c == 'u' && start > 0 && ls[start - 1].c == 'q' {
87            start += 1;
88        } else if ls[start].c == 'i' && start > 0 && ls[start - 1].c == 'g' {
89            start += 1;
90        }
91    }
92    if start > end {
93        start = end;
94    }
95    let count = end - start + 1;
96    // For triphthongs (3+ vowels), tone placement follows position rules —
97    // typically the middle vowel.  Don't let a variant (e.g. ô from oo in
98    // "oeo") steal the tone; the triphthong structure takes priority.
99    if count < 3 {
100        for i in (start..=end).rev() {
101            if ls[i].variant != 0 {
102                return Some(i);
103            }
104        }
105    }
106    if count == 1 {
107        return Some(start);
108    }
109    if count >= 3 {
110        if ls[start].c == 'u'
111            && start + 2 <= end
112            && ls[start + 1].c == 'y'
113            && ls[start + 2].c == 'e'
114        {
115            return Some(start + 2);
116        }
117        return Some(start + 1);
118    }
119    if end + 1 < ls.len() {
120        return Some(end);
121    }
122    let (c1, c2) = (ls[start].c, ls[end].c);
123    if (c1 == 'o' && c2 == 'a') || (c1 == 'o' && c2 == 'e') || (c1 == 'u' && c2 == 'y') {
124        // Open oa/oe/uy: modern→2nd vowel, traditional→1st vowel
125        // (closed oa/oe/uy always tone on last vowel — handled above)
126        return if modern { Some(end) } else { Some(start) };
127    }
128    Some(start) // default open diphthong: tone on first vowel
129}
130
131/// After a hook change (w key), recalculate the correct tone position and
132/// move the tone if it's on the wrong vowel. Ported from fcitx5-unikey's
133/// tone re-positioning in processHook / processHookWithUO.
134fn reposition_tone_after_hook(ls: &mut [Letter], modern: bool) {
135    let end = match last_vowel_index(ls) {
136        Some(e) => e,
137        None => return,
138    };
139    let mut start = end;
140    while start > 0 && ls[start - 1].is_vowel {
141        start -= 1;
142    }
143    // Skip gi/qu digraph vowels
144    if start < end {
145        if ls[start].c == 'u' && start > 0 && ls[start - 1].c == 'q' {
146            start += 1;
147        } else if ls[start].c == 'i' && start > 0 && ls[start - 1].c == 'g' {
148            start += 1;
149        }
150    }
151    if start > end {
152        return;
153    }
154
155    // Find which vowel in the cluster currently has tone
156    let cur_tone_idx = (start..=end).find(|&i| ls[i].tone != 0);
157    let tone_val = match cur_tone_idx {
158        Some(i) => ls[i].tone,
159        None => return, // no tone → nothing to reposition
160    };
161
162    // Find where tone SHOULD be after the hook change
163    let new_tone_idx = match tone_vowel_index(ls, modern) {
164        Some(i) => i,
165        None => return,
166    };
167
168    // Move tone if position changed
169    if Some(new_tone_idx) != cur_tone_idx {
170        if let Some(old) = cur_tone_idx {
171            ls[old].tone = 0;
172        }
173        ls[new_tone_idx].tone = tone_val;
174    }
175}
176
177fn telex_tone(c: char) -> Option<u8> {
178    match c.to_ascii_lowercase() {
179        's' => Some(2),
180        'f' => Some(1),
181        'r' => Some(3),
182        'x' => Some(4),
183        'j' => Some(5),
184        _ => None,
185    }
186}
187fn vni_tone(c: char) -> Option<u8> {
188    match c {
189        '1' => Some(2),
190        '2' => Some(1),
191        '3' => Some(3),
192        '4' => Some(4),
193        '5' => Some(5),
194        _ => None,
195    }
196}
197
198pub fn convert_telex(input: &str, modern: bool, short_w: bool) -> String {
199    convert_telex_impl(input, modern, short_w)
200}
201
202/// Telex conversion with auto-restore: if the composed output is not valid
203/// Vietnamese and differs from the raw input, return the raw input instead.
204/// Skips auto-restore when the input has Vietnamese composition markers
205/// (dd, vowel+w, double vowels) — those signal intentional IME transforms.
206pub fn convert_telex_auto_restore(input: &str, modern: bool, short_w: bool) -> String {
207    let composed = convert_telex_impl(input, modern, short_w);
208    let all_ascii = composed.chars().all(|c| c.is_ascii());
209    if composed != input
210        && !all_ascii
211        && !crate::spelling::is_valid_cvc(&composed)
212        && !crate::has_vn_markers(input)
213    {
214        input.to_string()
215    } else {
216        composed
217    }
218}
219
220fn convert_telex_impl(input: &str, modern: bool, short_w: bool) -> String {
221    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
222    for ch in input.chars() {
223        let lc = ch.to_ascii_lowercase();
224        let upper = ch.is_ascii_uppercase();
225        // dd → đ: merge once, unmerge once, then one-shot (like aa).
226        // dd→đ, ddd→dd, dddd→ddd (no re-merge after toggle-off).
227        if lc == 'd' {
228            let any_d_toggled = ls.iter().any(|lt| lt.c == 'd' && lt.d_toggled);
229            if !any_d_toggled && last_dstroke_index(&ls).is_none() {
230                // No đ yet and never toggled — convert plain d to đ.
231                if let Some(di) = last_d_index(&ls) {
232                    ls[di].is_dstroke = true;
233                    if upper {
234                        ls[di].upper = true;
235                    }
236                    continue;
237                }
238            } else if last_dstroke_index(&ls).is_some() {
239                // đ exists — toggle off once, mark as toggled.
240                if let Some(di) = last_dstroke_index(&ls) {
241                    ls[di].is_dstroke = false;
242                    ls[di].d_toggled = true;
243                    // fall through: push literal 'd'
244                }
245            }
246        }
247        // Circumflex toggle: a→â, â→a (in-place on matching vowel, like w).
248        // the circumflex, searching backwards through the vowel cluster
249        // (skipping y/u, so "vay"+a→"vây", "sau"+a→"sâu").
250        if matches!(lc, 'a' | 'e' | 'o') {
251            // Double-vowel circumflex: aa→â (merge), aaa→aa (unmerge once),
252            // then aaaa→aaa (no re-merge — circ_toggled blocks reapply).
253            let mut consumed = false;
254            if let Some(vi) = last_vowel_index(&ls) {
255                // If any matching vowel was already toggled off, don't reapply.
256                let any_circ_toggled = ls[..=vi]
257                    .iter()
258                    .any(|lt| lt.is_vowel && lt.c == lc && lt.circ_toggled);
259                if !any_circ_toggled {
260                    for i in (0..=vi).rev() {
261                        if ls[i].is_vowel {
262                            if ls[i].c == lc {
263                                if i < vi {
264                                    // Roof crossing rules (x-unikey VSeqList
265                                    // withRoof): a crosses y/u (ây, âu),
266                                    // e crosses u (êu), o crosses i
267                                    // (ôi, uôi). Any other intermediate
268                                    // vowel blocks the roof.
269                                    let blocked = ls[i + 1..=vi]
270                                        .iter()
271                                        .any(|lt| lt.is_vowel
272                                            && !matches!(
273                                                (lc, lt.c),
274                                                ('a', 'y' | 'u')
275                                                    | ('e', 'u')
276                                                    | ('o', 'i')
277                                            ));
278                                    if blocked {
279                                        break;
280                                    }
281                                }
282                                if ls[i].variant == 0 {
283                                    ls[i].variant = 1;
284                                    if upper {
285                                        ls[i].upper = true;
286                                    }
287                                    consumed = true;
288                                    break;
289                                }
290                                if ls[i].variant == 1 {
291                                    ls[i].variant = 0;
292                                    ls[i].circ_toggled = true;
293                                    break;
294                                } // unmerge once
295                                if ls[i].variant == 2 {
296                                    // Swap hook↔circumflex: ă→â, ơ→ô (tone
297                                    // stays on the vowel).  For a hooked o
298                                    // after a hooked u, unhook the u too
299                                    // (ươ→uô) — inverse of the w rule where
300                                    // hook replaces circumflex (uô→ươ).
301                                    ls[i].variant = 1;
302                                    if upper {
303                                        ls[i].upper = true;
304                                    }
305                                    if ls[i].c == 'o'
306                                        && i > 0
307                                        && ls[i - 1].is_vowel
308                                        && ls[i - 1].c == 'u'
309                                        && ls[i - 1].variant == 2
310                                    {
311                                        ls[i - 1].variant = 0;
312                                    }
313                                    consumed = true;
314                                    break;
315                                }
316                            }
317                        } else {
318                            break;
319                        }
320                    }
321                }
322            }
323            if consumed {
324                continue;
325            }
326        }
327        if lc == 'w' {
328            // Two-phase w handling (from fcitx5-unikey):
329            // Phase 1: try to hook/breve the current vowel cluster
330            // Phase 2: if not applicable, insert standalone ư (short_w)
331            let hook_result = crate::vseq::try_apply_hook(&mut ls);
332            match hook_result {
333                crate::vseq::HookResult::Applied => {
334                    // Hook changed vowel structure — tone may need repositioning
335                    reposition_tone_after_hook(&mut ls, modern);
336                    continue;
337                }
338                crate::vseq::HookResult::ToggledOff => {
339                    // Hook removed — tone may need repositioning
340                    reposition_tone_after_hook(&mut ls, modern);
341                    // w falls through as literal below
342                }
343                crate::vseq::HookResult::NotApplicable => {
344                    if crate::vseq::try_insert_horn(&mut ls, short_w, upper) {
345                        continue;
346                    }
347                    // w falls through as literal
348                }
349            }
350        }
351        if let Some(t) = telex_tone(lc) {
352            if let Some(vi) = tone_vowel_index(&ls, modern) {
353                ls[vi].tone = t;
354                continue; // replace existing tone
355            }
356        }
357        if lc == 'z' {
358            if let Some(vi) = tone_vowel_index(&ls, modern) {
359                if ls[vi].tone != 0 {
360                    ls[vi].tone = 0;
361                    continue;
362                }
363            }
364        }
365        ls.push(Letter::new(ch, upper));
366        // Horn propagation: ư+o→ươ, ơ+u→ươ within vowel cluster.
367        // When a horned vowel is followed by a compatible vowel, share horn.
368        if ls.len() >= 2 {
369            let last = ls.len() - 1;
370            if ls[last].is_vowel && ls[last].variant == 0 {
371                let prev = last - 1;
372                if ls[prev].is_vowel && ls[prev].variant == 2 {
373                    if ls[prev].c == 'u' && ls[last].c == 'o' {
374                        ls[last].variant = 2;
375                        ls[last].horn_propagated = true;
376                    }
377                    if ls[prev].c == 'o' && ls[last].c == 'u' {
378                        let has_u_horn_before = prev >= 1
379                            && ls[prev - 1].is_vowel
380                            && ls[prev - 1].c == 'u'
381                            && ls[prev - 1].variant == 2;
382                        if !has_u_horn_before {
383                            ls[last].variant = 2;
384                            ls[last].horn_propagated = true;
385                        }
386                    }
387                }
388            }
389        }
390        // Tone reassignment: when a new vowel is pushed, move tone if the
391        // vowel structure has changed.
392        // e.g. "gis"→"gí" then "a"→"giá" (tone moves from i to a).
393        // e.g. "ngoaf"→"ngòa" then "i"→"ngoài" (triphthong, tone to middle).
394        if ls.len() >= 2 {
395            let last = ls.len() - 1;
396            if ls[last].is_vowel {
397                // Find first vowel in the cluster
398                let first_vowel = (0..last).find(|&j| ls[j].is_vowel);
399                if let Some(fv) = first_vowel {
400                    let vcount = last - fv + 1;
401                    // gi/qu digraph: move tone from digraph vowel to new vowel
402                    if vcount >= 2 {
403                        for j in (fv..last).rev() {
404                            if ls[j].tone != 0 {
405                                let move_tone = (ls[j].c == 'i' && j > 0 && ls[j - 1].c == 'g')
406                                    || (ls[j].c == 'u' && j > 0 && ls[j - 1].c == 'q');
407                                if move_tone {
408                                    ls[last].tone = ls[j].tone;
409                                    ls[j].tone = 0;
410                                }
411                                break;
412                            }
413                        }
414                    }
415                    // Triphthong: when expanding from 2 to 3+ vowels, move
416                    // tone to the correct vowel (middle for most, 3rd for uyê).
417                    // Only for a genuine vowel cluster — a consonant in
418                    // between ("force" → f,o,c,e) must not count as a third
419                    // vowel, or the tone lands on the consonant and is lost.
420                    if vcount >= 3 && (fv..=last).all(|j| ls[j].is_vowel) {
421                        for j in (fv..last).rev() {
422                            if ls[j].tone != 0 {
423                                let t = ls[j].tone;
424                                ls[j].tone = 0;
425                                let target = if ls[fv].c == 'u'
426                                    && fv + 2 <= last
427                                    && ls[fv + 1].c == 'y'
428                                    && ls[fv + 2].c == 'e'
429                                {
430                                    fv + 2 // uyê → tone on ê
431                                } else {
432                                    fv + 1 // default → tone on middle vowel
433                                };
434                                // Guard: move only if target is within bounds
435                                // and the current tone is on a non-target vowel
436                                if target <= last && target != j {
437                                    ls[target].tone = t;
438                                } else {
439                                    ls[j].tone = t; // restore
440                                }
441                                break;
442                            }
443                        }
444                    }
445                }
446            } else if lc != 'w' {
447                // Trailing consonant: closed syllable → tone must be on
448                // last vowel. e.g. "tof"→"tò" then "a" then "n"→"toàn".
449                // Skip for literal 'w' — w is never a Vietnamese final consonant.
450                if let Some(end) = last_vowel_index(&ls) {
451                    if end + 1 < ls.len() {
452                        let fv = (0..end).find(|&j| ls[j].is_vowel);
453                        if let Some(fv) = fv {
454                            if fv < end {
455                                for j in (fv..end).rev() {
456                                    if ls[j].tone != 0 {
457                                        ls[end].tone = ls[j].tone;
458                                        ls[j].tone = 0;
459                                        break;
460                                    }
461                                }
462                            }
463                        }
464                    }
465                }
466            }
467        }
468    }
469    emit(&ls)
470}
471
472/// Combined Telex + VNI mode.
473/// Accepts BOTH Telex and VNI keystrokes in one stream:
474///   - VNI digits (0-9) apply VNI tone/marks to the last vowel
475///   - Telex markers (w, s/f/r/x/j/z, and double-vowel ^/breve) apply Telex
476///   - everything else is a normal letter
477pub fn convert_teip_vni(input: &str, modern: bool, short_w: bool) -> String {
478    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
479    for ch in input.chars() {
480        let lc = ch.to_ascii_lowercase();
481        let upper = ch.is_ascii_uppercase();
482
483        // ── VNI digit rules ──
484        if let Some(t) = vni_tone(lc) {
485            if let Some(vi) = tone_vowel_index(&ls, modern) {
486                ls[vi].tone = t;
487                continue;
488            }
489        }
490        if lc >= '0' && lc <= '9' {
491            match lc {
492                '6' => {
493                    if let Some(vi) = last_vowel_index(&ls) {
494                        if matches!(ls[vi].c, 'a' | 'e' | 'o') {
495                            ls[vi].variant = 1;
496                            continue;
497                        }
498                    }
499                }
500                '7' => {
501                    if let Some(vi) = last_vowel_index(&ls) {
502                        if matches!(ls[vi].c, 'o' | 'u') {
503                            ls[vi].variant = 2;
504                            if ls[vi].c == 'o' && vi > 0 && ls[vi - 1].c == 'u' {
505                                ls[vi - 1].variant = 2;
506                            }
507                            continue;
508                        }
509                    }
510                }
511                '8' => {
512                    if let Some(vi) = last_vowel_index(&ls) {
513                        if ls[vi].c == 'a' {
514                            ls[vi].variant = 2;
515                            continue;
516                        }
517                    }
518                }
519                '9' => {
520                    if let Some(di) = last_d_index(&ls) {
521                        ls[di].is_dstroke = true;
522                        if upper {
523                            ls[di].upper = true;
524                        }
525                    }
526                    continue;
527                }
528                '0' => {
529                    continue;
530                }
531                _ => {} // digits 1-5 already handled by vni_tone above
532            }
533        }
534
535        // ── Telex dd → đ: merge once, unmerge once, then one-shot ──
536        if lc == 'd' {
537            let any_d_toggled = ls.iter().any(|lt| lt.c == 'd' && lt.d_toggled);
538            if !any_d_toggled && last_dstroke_index(&ls).is_none() {
539                if let Some(di) = last_d_index(&ls) {
540                    ls[di].is_dstroke = true;
541                    if upper {
542                        ls[di].upper = true;
543                    }
544                    continue;
545                }
546            } else if last_dstroke_index(&ls).is_some() {
547                if let Some(di) = last_dstroke_index(&ls) {
548                    ls[di].is_dstroke = false;
549                    ls[di].d_toggled = true;
550                    // fall through: push literal 'd'
551                }
552            }
553        }
554
555        // ── Circumflex toggle: a→â (in-place, like w) ──
556        if matches!(lc, 'a' | 'e' | 'o') {
557            // Double-vowel circumflex: aa→â (merge), aaa→aa (unmerge once),
558            // then aaaa→aaa (no re-merge — circ_toggled blocks reapply).
559            let mut consumed = false;
560            if let Some(vi) = last_vowel_index(&ls) {
561                // If any matching vowel was already toggled off, don't reapply.
562                let any_circ_toggled = ls[..=vi]
563                    .iter()
564                    .any(|lt| lt.is_vowel && lt.c == lc && lt.circ_toggled);
565                if !any_circ_toggled {
566                    for i in (0..=vi).rev() {
567                        if ls[i].is_vowel {
568                            if ls[i].c == lc {
569                                if i < vi {
570                                    // Roof crossing rules (x-unikey VSeqList
571                                    // withRoof): a crosses y/u (ây, âu),
572                                    // e crosses u (êu), o crosses i
573                                    // (ôi, uôi). Any other intermediate
574                                    // vowel blocks the roof.
575                                    let blocked = ls[i + 1..=vi]
576                                        .iter()
577                                        .any(|lt| lt.is_vowel
578                                            && !matches!(
579                                                (lc, lt.c),
580                                                ('a', 'y' | 'u')
581                                                    | ('e', 'u')
582                                                    | ('o', 'i')
583                                            ));
584                                    if blocked {
585                                        break;
586                                    }
587                                }
588                                if ls[i].variant == 0 {
589                                    ls[i].variant = 1;
590                                    if upper {
591                                        ls[i].upper = true;
592                                    }
593                                    consumed = true;
594                                    break;
595                                }
596                                if ls[i].variant == 1 {
597                                    ls[i].variant = 0;
598                                    ls[i].circ_toggled = true;
599                                    break;
600                                } // unmerge once
601                                if ls[i].variant == 2 {
602                                    // Swap hook↔circumflex: ă→â, ơ→ô (tone
603                                    // stays on the vowel).  For a hooked o
604                                    // after a hooked u, unhook the u too
605                                    // (ươ→uô) — inverse of the w rule where
606                                    // hook replaces circumflex (uô→ươ).
607                                    ls[i].variant = 1;
608                                    if upper {
609                                        ls[i].upper = true;
610                                    }
611                                    if ls[i].c == 'o'
612                                        && i > 0
613                                        && ls[i - 1].is_vowel
614                                        && ls[i - 1].c == 'u'
615                                        && ls[i - 1].variant == 2
616                                    {
617                                        ls[i - 1].variant = 0;
618                                    }
619                                    consumed = true;
620                                    break;
621                                }
622                            }
623                        } else {
624                            break;
625                        }
626                    }
627                }
628            }
629            if consumed {
630                continue;
631            }
632        }
633
634        // ── Telex w (breve/horn) ──
635        if lc == 'w' {
636            let hook_result = crate::vseq::try_apply_hook(&mut ls);
637            match hook_result {
638                crate::vseq::HookResult::Applied => {
639                    reposition_tone_after_hook(&mut ls, modern);
640                    continue;
641                }
642                crate::vseq::HookResult::ToggledOff => {
643                    reposition_tone_after_hook(&mut ls, modern);
644                    // w falls through as literal
645                }
646                crate::vseq::HookResult::NotApplicable => {
647                    if crate::vseq::try_insert_horn(&mut ls, short_w, upper) {
648                        continue;
649                    }
650                }
651            }
652        }
653
654        // ── Telex tone marks ──
655        if let Some(t) = telex_tone(lc) {
656            if let Some(vi) = tone_vowel_index(&ls, modern) {
657                ls[vi].tone = t;
658                continue; // replace existing tone
659            }
660        }
661        if lc == 'z' {
662            if let Some(vi) = tone_vowel_index(&ls, modern) {
663                if ls[vi].tone != 0 {
664                    ls[vi].tone = 0;
665                    continue;
666                }
667            }
668        }
669
670        ls.push(Letter::new(ch, upper));
671        // Tone reassignment: when a new vowel is pushed, move tone if the
672        // vowel structure has changed (gi/qu digraph, triphthong expansion).
673        if ls.len() >= 2 {
674            let last = ls.len() - 1;
675            if ls[last].is_vowel {
676                let first_vowel = (0..last).find(|&j| ls[j].is_vowel);
677                if let Some(fv) = first_vowel {
678                    let vcount = last - fv + 1;
679                    // gi/qu digraph: move tone from digraph vowel to new vowel
680                    if vcount >= 2 {
681                        for j in (fv..last).rev() {
682                            if ls[j].tone != 0 {
683                                let move_tone = (ls[j].c == 'i' && j > 0 && ls[j - 1].c == 'g')
684                                    || (ls[j].c == 'u' && j > 0 && ls[j - 1].c == 'q');
685                                if move_tone {
686                                    ls[last].tone = ls[j].tone;
687                                    ls[j].tone = 0;
688                                }
689                                break;
690                            }
691                        }
692                    }
693                    // Triphthong expansion: 2→3+ vowels, move tone to
694                    // correct position (middle vowel, or 3rd for uyê).
695                    if vcount >= 3 {
696                        for j in (fv..last).rev() {
697                            if ls[j].tone != 0 {
698                                let t = ls[j].tone;
699                                ls[j].tone = 0;
700                                let target = if ls[fv].c == 'u'
701                                    && fv + 2 <= last
702                                    && ls[fv + 1].c == 'y'
703                                    && ls[fv + 2].c == 'e'
704                                {
705                                    fv + 2
706                                } else {
707                                    fv + 1
708                                };
709                                if target <= last && target != j {
710                                    ls[target].tone = t;
711                                } else {
712                                    ls[j].tone = t;
713                                }
714                                break;
715                            }
716                        }
717                    }
718                }
719            } else {
720                // Trailing consonant: closed syllable → tone must be on
721                // last vowel.
722                if let Some(end) = last_vowel_index(&ls) {
723                    if end + 1 < ls.len() {
724                        let fv = (0..end).find(|&j| ls[j].is_vowel);
725                        if let Some(fv) = fv {
726                            if fv < end {
727                                for j in (fv..end).rev() {
728                                    if ls[j].tone != 0 {
729                                        ls[end].tone = ls[j].tone;
730                                        ls[j].tone = 0;
731                                        break;
732                                    }
733                                }
734                            }
735                        }
736                    }
737                }
738            }
739        }
740    }
741    emit(&ls)
742}
743
744/// VIQR (Vietnamese Quoted-Readable) conversion.
745///
746/// Uses ASCII punctuation as diacritic markers:
747///   ^ circumflex, ( breve, + horn (modifiers)
748///   ' sac, ` huyen, ? hoi, ~ nga, . nang (tones)
749///   dd -> đ d-stroke
750pub fn convert_viqr(input: &str) -> String {
751    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
752    for ch in input.chars() {
753        let lc = ch.to_ascii_lowercase();
754        let n = ls.len();
755
756        // Modifier characters: apply to last vowel
757        match ch {
758            '^' | '(' | '+' => {
759                if let Some(vi) = last_vowel_index(&ls) {
760                    match (ch, ls[vi].c) {
761                        ('^', 'a' | 'e' | 'o') => {
762                            ls[vi].variant = 1;
763                            continue;
764                        }
765                        ('(', 'a') => {
766                            ls[vi].variant = 2;
767                            continue;
768                        }
769                        ('+', 'o' | 'u') => {
770                            ls[vi].variant = 2;
771                            continue;
772                        }
773                        _ => {} // illegal combination: fall through to literal
774                    }
775                }
776                // Fall through: treat as literal character
777                ls.push(Letter {
778                    c: ch,
779                    is_vowel: false,
780                    variant: 0,
781                    tone: 0,
782                    is_dstroke: false,
783                    upper: false,
784                    from_w: false,
785                    horn_propagated: false,
786                    circ_toggled: false,
787                    d_toggled: false,
788                    horn_toggled: false,
789                });
790                continue;
791            }
792            _ => {}
793        }
794
795        // Tone characters
796        match ch {
797            '\'' => {
798                if let Some(vi) = last_vowel_index(&ls) {
799                    ls[vi].tone = 2;
800                    continue;
801                }
802            }
803            '`' => {
804                if let Some(vi) = last_vowel_index(&ls) {
805                    ls[vi].tone = 1;
806                    continue;
807                }
808            }
809            '?' => {
810                if let Some(vi) = last_vowel_index(&ls) {
811                    ls[vi].tone = 3;
812                    continue;
813                }
814            }
815            '~' => {
816                if let Some(vi) = last_vowel_index(&ls) {
817                    ls[vi].tone = 4;
818                    continue;
819                }
820            }
821            '.' => {
822                if let Some(vi) = last_vowel_index(&ls) {
823                    ls[vi].tone = 5;
824                    continue;
825                }
826            }
827            _ => {}
828        }
829
830        // dd → đ
831        if lc == 'd' && n > 0 && ls[n - 1].c == 'd' && !ls[n - 1].is_vowel && !ls[n - 1].is_dstroke
832        {
833            ls[n - 1].is_dstroke = true;
834            if ch.is_ascii_uppercase() {
835                ls[n - 1].upper = true;
836            }
837            continue;
838        }
839
840        ls.push(Letter::new(ch, ch.is_ascii_uppercase()));
841    }
842    emit(&ls)
843}
844
845pub fn convert_vni(input: &str, modern: bool, _short_w: bool) -> String {
846    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
847    for ch in input.chars() {
848        let lc = ch.to_ascii_lowercase();
849        let upper = ch.is_ascii_uppercase();
850        if let Some(t) = vni_tone(lc) {
851            if let Some(vi) = tone_vowel_index(&ls, modern) {
852                ls[vi].tone = t;
853                continue;
854            }
855        }
856        match lc {
857            '6' => {
858                if let Some(vi) = last_vowel_index(&ls) {
859                    if matches!(ls[vi].c, 'a' | 'e' | 'o') {
860                        ls[vi].variant = 1;
861                        continue;
862                    }
863                }
864            }
865            '7' => {
866                if let Some(vi) = last_vowel_index(&ls) {
867                    if matches!(ls[vi].c, 'o' | 'u') {
868                        ls[vi].variant = 2;
869                        if ls[vi].c == 'o' && vi > 0 && ls[vi - 1].c == 'u' {
870                            ls[vi - 1].variant = 2;
871                        }
872                        continue;
873                    }
874                }
875            }
876            '8' => {
877                if let Some(vi) = last_vowel_index(&ls) {
878                    if ls[vi].c == 'a' {
879                        ls[vi].variant = 2;
880                        continue;
881                    }
882                }
883            }
884            '9' => {
885                if let Some(di) = last_d_index(&ls) {
886                    ls[di].is_dstroke = true;
887                    if upper {
888                        ls[di].upper = true;
889                    }
890                }
891                continue;
892            }
893            '0' => {
894                continue;
895            }
896            _ => {}
897        }
898        if lc.is_ascii_alphabetic() || (lc.is_ascii_digit() && !ls.is_empty()) {
899            ls.push(Letter::new(ch, upper));
900        }
901    }
902    emit(&ls)
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    // modern=true, short_w=false (current default behavior)
909    fn t(s: &str) -> String {
910        convert_telex(s, true, false)
911    }
912    fn tw(s: &str) -> String {
913        convert_telex(s, true, true)
914    }
915    // traditional tone placement (short_w enabled)
916    fn tt(s: &str) -> String {
917        convert_telex(s, false, true)
918    }
919    fn vt(s: &str) -> String {
920        convert_vni(s, false, true)
921    }
922
923    #[test]
924    fn telex_basic() {
925        assert_eq!(t("as"), "á");
926        assert_eq!(t("af"), "à");
927        assert_eq!(t("ar"), "ả");
928        assert_eq!(t("ax"), "ã");
929        assert_eq!(t("aj"), "ạ");
930        assert_eq!(t("aw"), "ă");
931        assert_eq!(t("aa"), "â");
932        assert_eq!(t("ee"), "ê");
933        assert_eq!(t("oo"), "ô");
934        assert_eq!(t("ow"), "ơ");
935        assert_eq!(t("uw"), "ư");
936        assert_eq!(t("dd"), "đ");
937    }
938    #[test]
939    fn telex_compound() {
940        assert_eq!(t("aws"), "ắ");
941        assert_eq!(t("aas"), "ấ");
942        assert_eq!(t("ees"), "ế");
943        assert_eq!(t("oos"), "ố");
944        assert_eq!(t("ows"), "ớ");
945        assert_eq!(t("uws"), "ứ");
946    }
947    #[test]
948    fn telex_tone_not_lost_on_consonant_gap() {
949        // A consonant between vowels must not be counted as a vowel
950        // cluster member — the tone stays on the real vowel.
951        assert_eq!(t("for"), "fỏ");
952        assert_eq!(t("force"), "fỏce");
953        assert_eq!(t("vai"), "vai");
954        assert_eq!(t("vaif"), "vài");
955    }
956    #[test]
957    fn telex_words() {
958        assert_eq!(t("xin"), "xin");
959        assert_eq!(t("tooi"), "tôi");
960        assert_eq!(t("laf"), "là");
961        assert_eq!(t("cos"), "có");
962        assert_eq!(t("khoong"), "không");
963        assert_eq!(t("tieengs"), "tiếng");
964        assert_eq!(t("Vieetj"), "Việt");
965    }
966    #[test]
967    fn telex_uow() {
968        assert_eq!(t("uow"), "ươ");
969        assert_eq!(t("cuowcs"), "cước");
970        assert_eq!(t("dduwowcj"), "được");
971        assert_eq!(t("nguwowif"), "người");
972        // Horn propagation: ư+o→ươ (single w serves both vowels)
973        assert_eq!(t("uwocs"), "ước");
974        assert_eq!(t("truwocs"), "trước");
975        assert_eq!(t("luwowcs"), "lước"); // explicit w on both — still works (sắc → ước)
976        assert_eq!(t("truwowcs"), "trước"); // "uwo" abbreviated = "uow" long form
977                                            // Triphthong: horn does not propagate further
978        assert_eq!(t("uowus"), "ướu"); // ươu — horn stays, u unmodified
979        assert_eq!(t("ruowus"), "rướu");
980        // w replaces circumflex with hook (matches unikey): ô→ơ, â→ă
981        assert_eq!(t("luoojcw"), "lược"); // luộc + w → lược
982        assert_eq!(t("oow"), "ơ");
983        assert_eq!(t("coow"), "cơ");
984        assert_eq!(t("aaw"), "ă");
985        assert_eq!(t("ooiw"), "ơi"); // ôi + w → ơi
986        assert_eq!(t("uooiw"), "ươi");
987    }
988    #[test]
989    fn telex_gi_qu() {
990        assert_eq!(t("gif"), "gì");
991        assert_eq!(t("giowf"), "giờ");
992        assert_eq!(t("gioir"), "giỏi");
993        assert_eq!(t("gias"), "giá");
994        assert_eq!(t("quas"), "quá");
995    }
996    #[test]
997    fn telex_tone_placement() {
998        assert_eq!(t("chafo"), "chào");
999        assert_eq!(t("toans"), "toán");
1000        assert_eq!(t("hoaf"), "hoà");
1001        assert_eq!(t("thowif"), "thời");
1002        assert_eq!(t("cuar"), "của");
1003        assert_eq!(t("thoix"), "thõi");
1004        // òa/oà both accessible via typing order:
1005        assert_eq!(t("ofa"), "òa"); // tone between vowels → on first vowel
1006        assert_eq!(t("oaf"), "oà"); // tone after all vowels → on second vowel
1007    }
1008    #[test]
1009    fn telex_english() {
1010        assert_eq!(t("hello"), "hello");
1011        assert_eq!(t("wood"), "wôd");
1012        assert_eq!(t("reboot"), "rebôt");
1013    }
1014    #[test]
1015    fn telex_z() {
1016        assert_eq!(t("asz"), "a");
1017    }
1018    #[test]
1019    fn telex_w_standalone() {
1020        assert_eq!(t("w"), "w"); // without short_w: w stays as w
1021        assert_eq!(tw("w"), "ư"); // with short_w: standalone w → ư
1022        assert_eq!(tw("ws"), "ứ");
1023        assert_eq!(tw("wf"), "ừ");
1024        assert_eq!(tw("wn"), "ưn");
1025    }
1026
1027    // ── traditional tone placement (modern=false, short_w=true) ─────────────
1028
1029    #[test]
1030    fn telex_dd_traditional() {
1031        assert_eq!(tt("dd"), "đ");
1032        assert_eq!(tt("DD"), "Đ");
1033        assert_eq!(tt("Dd"), "Đ");
1034        assert_eq!(tt("dD"), "Đ");
1035    }
1036
1037    #[test]
1038    fn telex_dd_across_chars() {
1039        // dd → đ works across intervening characters (Unikey-compatible)
1040        assert_eq!(tt("dad"), "đa");
1041        assert_eq!(tt("dda"), "đa");
1042        assert_eq!(tt("dadf"), "đà");
1043        assert_eq!(tt("dads"), "đá");
1044        assert_eq!(tt("dadr"), "đả");
1045        assert_eq!(tt("dadx"), "đã");
1046        assert_eq!(tt("dadj"), "đạ");
1047        assert_eq!(tt("dangd"), "đang"); // second d converts first
1048        assert_eq!(tt("dangdf"), "đàng"); // d...d converts first d
1049        assert_eq!(tt("DAD"), "ĐA");
1050        assert_eq!(tt("Dad"), "Đa");
1051        // Toggle once, then one-shot (like aa): dd→đ, ddd→dd, dddd→ddd
1052        assert_eq!(tt("dadd"), "dad");
1053        assert_eq!(tt("ddd"), "dd");     // toggle off once
1054        assert_eq!(tt("dddd"), "ddd");   // no re-merge, plain d's
1055        assert_eq!(tt("ddddd"), "dddd"); // plain d's
1056        assert_eq!(tt("ddda"), "dda");   // plain d's
1057        assert_eq!(tt("ddddf"), "dddf"); // tone falls through on plain d's
1058    }
1059    #[test]
1060    fn telex_marks_traditional() {
1061        assert_eq!(tt("aa"), "â");
1062        assert_eq!(tt("aw"), "ă");
1063        assert_eq!(tt("ee"), "ê");
1064        assert_eq!(tt("ew"), "ew"); // ew no longer → ê (use ee)
1065        assert_eq!(tt("oo"), "ô");
1066        assert_eq!(tt("ow"), "ơ");
1067        assert_eq!(tt("uw"), "ư");
1068        assert_eq!(tt("uow"), "ươ");
1069    }
1070    #[test]
1071    fn telex_w_traditional() {
1072        assert_eq!(tt("w"), "ư");
1073        assert_eq!(tt("W"), "Ư");
1074        assert_eq!(tt("wn"), "ưn");
1075        assert_eq!(tt("ws"), "ứ");
1076        assert_eq!(tt("wf"), "ừ");
1077    }
1078    #[test]
1079    fn telex_compound_traditional() {
1080        assert_eq!(tt("aws"), "ắ");
1081        assert_eq!(tt("aas"), "ấ");
1082        assert_eq!(tt("ees"), "ế");
1083        assert_eq!(tt("oos"), "ố");
1084        assert_eq!(tt("ows"), "ớ");
1085        assert_eq!(tt("uws"), "ứ");
1086    }
1087    #[test]
1088    fn telex_words_traditional() {
1089        assert_eq!(tt("chaof"), "chào");
1090        assert_eq!(tt("vieejt"), "việt");
1091        assert_eq!(tt("tieengs"), "tiếng");
1092        assert_eq!(tt("quas"), "quá");
1093        assert_eq!(tt("gias"), "giá");
1094        assert_eq!(tt("xin"), "xin");
1095        assert_eq!(tt("tooi"), "tôi");
1096        assert_eq!(tt("laf"), "là");
1097        assert_eq!(tt("cos"), "có");
1098        assert_eq!(tt("khoong"), "không");
1099        assert_eq!(tt("Vieetj"), "Việt");
1100    }
1101    #[test]
1102    fn telex_tone_traditional() {
1103        // traditional: tone on 2nd vowel for oa/oe/uy open diphthongs
1104        assert_eq!(tt("toans"), "toán");
1105        assert_eq!(tt("hoaf"), "hòa");
1106        assert_eq!(tt("thowif"), "thời");
1107        assert_eq!(tt("cuar"), "của");
1108    }
1109    #[test]
1110    fn telex_gi_qu_traditional() {
1111        assert_eq!(tt("gif"), "gì");
1112        assert_eq!(tt("giowf"), "giờ");
1113        assert_eq!(tt("gioir"), "giỏi");
1114    }
1115
1116    #[test]
1117    fn telex_gi_qu_tone_reassign() {
1118        // Incremental typing: tone moves from digraph vowel to new vowel
1119        assert_eq!(tt("gisa"), "giá"); // gis→gí + a → giá
1120        assert_eq!(tt("gifa"), "già"); // gif→gì + a → già
1121        assert_eq!(tt("quisa"), "quía"); // qus→qú + i + a → quía
1122        assert_eq!(tt("qufa"), "quà"); // quf→qù + a → quà
1123                                       // Multi-vowel after gi: tone moves to last vowel
1124        assert_eq!(tt("gias"), "giá"); // already works in one shot
1125        assert_eq!(tt("giois"), "giói"); // gio + is → giói
1126    }
1127
1128    #[test]
1129    fn telex_trailing_consonant_reassign() {
1130        // Tone between vowels, then consonant closes syllable
1131        // → tone moves to last vowel (correct Vietnamese spelling)
1132        assert_eq!(tt("tofan"), "toàn"); // tof→tò + a + n → toàn
1133        assert_eq!(tt("toafn"), "toàn"); // toa→toa + f→tòa + n→toàn
1134        assert_eq!(tt("hoafn"), "hoàn"); // hoaf→hòa + n→hoàn
1135                                         // One-shot: tone after all vowels already works correctly
1136        assert_eq!(tt("toanf"), "toàn");
1137        assert_eq!(tt("hoanf"), "hoàn");
1138    }
1139    #[test]
1140    fn telex_uow_traditional() {
1141        assert_eq!(tt("cuowcs"), "cước");
1142        assert_eq!(tt("dduwowcj"), "được");
1143        assert_eq!(tt("nguwowif"), "người");
1144    }
1145    #[test]
1146    fn telex_z_traditional() {
1147        assert_eq!(tt("asz"), "a");
1148    }
1149    #[test]
1150    fn telex_english_traditional() {
1151        assert_eq!(tt("hello"), "hello");
1152    }
1153    #[test]
1154    fn telex_more_traditional() {
1155        assert_eq!(tt("hoanf"), "hoàn");
1156        assert_eq!(tt("huyeenf"), "huyền");
1157        assert_eq!(tt("nguyeexn"), "nguyễn");
1158        assert_eq!(tt("ngoaif"), "ngoài");
1159        assert_eq!(tt("thuowngf"), "thường");
1160    }
1161    #[test]
1162    fn vni_marks_traditional() {
1163        assert_eq!(vt("d9"), "đ");
1164        assert_eq!(vt("a6"), "â");
1165        assert_eq!(vt("a8"), "ă");
1166        assert_eq!(vt("e6"), "ê");
1167        assert_eq!(vt("o6"), "ô");
1168        assert_eq!(vt("o7"), "ơ");
1169        assert_eq!(vt("u7"), "ư");
1170        assert_eq!(vt("uo7"), "ươ");
1171    }
1172    #[test]
1173    fn vni_words_traditional() {
1174        assert_eq!(vt("vie65t"), "việt");
1175        assert_eq!(vt("tie61ng"), "tiếng");
1176        assert_eq!(vt("qua1"), "quá");
1177        assert_eq!(vt("gia1"), "giá");
1178    }
1179
1180    // ── Tone replacement tests ─────────────────────────────────────
1181    #[test]
1182    fn telex_tone_replace() {
1183        assert_eq!(tt("saosf"), "sào"); // sáo + f → sào (acute→grave)
1184        assert_eq!(tt("saojs"), "sáo"); // sạ + s → sáo (dot→acute)
1185        assert_eq!(tt("saojr"), "sảo"); // sạo + r → sảo (dot→hook)
1186        assert_eq!(tt("hoasf"), "hòa"); // hóa + f → hòa (acute→grave, traditional on o)
1187        assert_eq!(tt("toansr"), "toản"); // toán + r → toản (acute→hook)
1188    }
1189    #[test]
1190    fn teipvni_tone_replace() {
1191        assert_eq!(tv("saosf"), "sào"); // Telex acute→grave in combined mode
1192        assert_eq!(tv("sao1f"), "sào"); // VNI acute + Telex grave
1193    }
1194
1195    // ── Double-vowel: merge once, unmerge once, then one-shot ───────
1196    #[test]
1197    fn telex_double_vowel_toggle() {
1198        assert_eq!(tt("aa"), "â");
1199        assert_eq!(tt("aaa"), "aa");    // unmerge once
1200        assert_eq!(tt("aaaa"), "aaa");  // no re-merge, plain a's after
1201        assert_eq!(tt("ee"), "ê");
1202        assert_eq!(tt("eee"), "ee");
1203        assert_eq!(tt("eeee"), "eee");  // no re-merge
1204        assert_eq!(tt("oo"), "ô");
1205        assert_eq!(tt("ooo"), "oo");
1206        assert_eq!(tt("oooo"), "ooo");
1207        assert_eq!(tt("roo"), "rô");
1208        assert_eq!(tt("rooot"), "root"); // oo→ô, ooo→oo, t
1209    }
1210
1211    // ── Double-vowel across y/u (vay+a→vây) ────────────────────────
1212    #[test]
1213    fn telex_double_vowel_across_y() {
1214        assert_eq!(tt("vaya"), "vây");
1215        assert_eq!(tt("vayaj"), "vậy");
1216        assert_eq!(tt("vayas"), "vấy");
1217        assert_eq!(tt("vayaf"), "vầy");
1218        assert_eq!(tt("mayas"), "mấy");
1219        assert_eq!(tt("dayaj"), "dậy");
1220        assert_eq!(tt("quaya"), "quây");
1221        assert_eq!(tt("quayar"), "quẩy");
1222        assert_eq!(tt("saua"), "sâu");
1223        assert_eq!(tt("sauas"), "sấu");
1224        assert_eq!(tt("dauaf"), "dầu");
1225        assert_eq!(tt("aay"), "ây");
1226    }
1227
1228    // ── Double-vowel on a hooked vowel swaps hook↔circumflex ───────
1229    #[test]
1230    fn telex_double_vowel_swap() {
1231        // ă + a → â (tone preserved): lắm + a → lấm
1232        assert_eq!(tt("lawsma"), "lấm"); // lắm + a (tone after final consonant)
1233        assert_eq!(t("lawsma"), "lấm"); // modern tone placement — same result
1234        assert_eq!(tt("awsa"), "ấ"); // ắ + a → ấ
1235        assert_eq!(tt("luowjco"), "luộc"); // lược + o → luộc (ươ→uô, inverse of uô+w→ươ)
1236        assert_eq!(tv("lawsma"), "lấm"); // TeipVni combined mode too
1237                                         // One-shot after swap: second a unmerges â→a like the aa/aaa pattern.
1238        assert_eq!(tt("lawsmaa"), "láma"); // lấm + a → lám + literal a
1239    }
1240
1241    // ── Roof (circumflex) crossing rules — x-unikey VSeqList parity ──
1242    // a crosses y/u (ây, âu), e crosses u (êu), o crosses i (ôi/uôi).
1243    #[test]
1244    fn telex_roof_crossing() {
1245        // o crosses i: oi + o → ôi (x-unikey: vs_oi withRoof = vs_ori)
1246        assert_eq!(tt("oio"), "ôi");
1247        assert_eq!(t("oio"), "ôi");
1248        assert_eq!(tt("ddoio"), "đôi");
1249        assert_eq!(tt("đoio"), "đôi"); // literal đ (surrounding-text rebuild)
1250        assert_eq!(tt("doio"), "dôi");
1251        assert_eq!(tt("oiof"), "ồi"); // tone after roof lands on ô
1252        // uoi + o → uôi (roof at position 1, x-unikey vs_uoi → vs_uori)
1253        assert_eq!(tt("uoio"), "uôi");
1254        assert_eq!(tt("uoiof"), "uồi");
1255        // ơi + o → ôi and ươi + o → uôi (hook↔circumflex swap across i)
1256        assert_eq!(tt("owio"), "ôi");
1257        assert_eq!(tt("uowio"), "uôi");
1258        assert_eq!(tv("oio"), "ôi"); // TeipVni combined mode too
1259                                     // ai + a stays aia — x-unikey vs_ai has no withRoof (blocks i for a)
1260        assert_eq!(tt("aia"), "aia");
1261        assert_eq!(tt("eie"), "eie"); // ei is not a roofable cluster either
1262        // a still crosses y/u, e crosses u (existing behavior)
1263        assert_eq!(tt("vaya"), "vây");
1264        assert_eq!(tt("saua"), "sâu");
1265        assert_eq!(tt("eue"), "êu");
1266        // o does NOT cross u/y (x-unikey vs_uou / oy have no withRoof)
1267        assert_eq!(tt("uouo"), "uouo");
1268        assert_eq!(tt("oyo"), "oyo");
1269    }
1270
1271    // ── w-toggle: pressing w again undoes the mark ──────────────────
1272    #[test]
1273    fn telex_w_toggle() {
1274        // Standalone w → ư, ww → ư→w + literal w → ww
1275        // Toggle-off now also produces a literal w (ow + w → ow, not o)
1276        assert_eq!(tt("w"), "ư");
1277        assert_eq!(tt("ww"), "w"); // ư→w toggle (consumed)
1278        assert_eq!(tt("www"), "ww"); // w literal + w literal
1279        assert_eq!(tt("wws"), "ws"); // w→ư, w→w (toggle), s has no vowel → ws
1280    }
1281    #[test]
1282    fn telex_w_toggle_regular() {
1283        assert_eq!(tt("aw"), "ă"); // a+w → ă
1284        assert_eq!(tt("aww"), "aw"); // ă+w → a + literal w → aw
1285        assert_eq!(tt("ow"), "ơ"); // o+w → ơ
1286        assert_eq!(tt("oww"), "ow"); // ơ+w → o + literal w → ow
1287        assert_eq!(tt("uw"), "ư"); // u+w → ư
1288        assert_eq!(tt("uww"), "uw"); // ư+w → u + literal w → uw
1289                                     // e+w no longer → ê — use ee for circumflex
1290        assert_eq!(tt("ew"), "ew");
1291        assert_eq!(tt("uow"), "ươ"); // u+o+w → ươ
1292        assert_eq!(tt("uoww"), "uow"); // ươ+w → uo + literal w → uow
1293        // Triple-w: one-shot after toggle (like aa→â, aaa→aa, aaaa→aaa)
1294        assert_eq!(tt("owww"), "oww"); // ơ→o toggle off once, then literal w's
1295        assert_eq!(tt("uowww"), "uoww"); // ươ→uo toggle off once, then literal w's
1296                                       // uu + w → ưu (horn on first u of cluster, matches Unikey Windows)
1297        assert_eq!(tt("uuw"), "ưu");
1298        assert_eq!(tt("uuww"), "uuw"); // 2nd w: toggle ưu→uu + literal w → uuw
1299        assert_eq!(tt("uuws"), "ứu"); // uuw + acute
1300    }
1301
1302    // ── w-target tests: w applies to last modifiable vowel ──────────
1303    #[test]
1304    fn telex_w_target() {
1305        // toi+w: w modifies 'o' (last modifiable), not 'i'
1306        assert_eq!(tt("toiw"), "tơi");
1307        assert_eq!(tt("toiws"), "tới"); // toi + w + sắc → tới
1308        assert_eq!(tt("toiwf"), "tời"); // toi + w + huyền → tời
1309        assert_eq!(tt("toiwr"), "tởi"); // toi + w + hỏi → tởi
1310        assert_eq!(tt("toiwj"), "tợi"); // toi + w + nặng → tợi
1311                                        // nguoi + w + f = người (w→ơ on o, uow→ươ, f→huyền on ư)
1312        assert_eq!(tt("nguoiwf"), "người");
1313        assert_eq!(tt("nguoiws"), "ngưới");
1314        // muoi + w + f = mười
1315        assert_eq!(tt("muoiwf"), "mười");
1316        // tuoi + w + s = tưới
1317        assert_eq!(tt("tuoiws"), "tưới");
1318        // "tuổi" correctly typed as tuooir (oo→ô)
1319        assert_eq!(tt("tuooir"), "tuổi");
1320        assert_eq!(tt("tuooif"), "tuồi");
1321    }
1322    #[test]
1323    fn telex_toan() {
1324        // "toàn" has trailing consonant → tone on 2nd vowel always
1325        assert_eq!(tt("toanf"), "toàn");
1326        assert_eq!(t("toanf"), "toàn");
1327        assert_eq!(tt("hoanf"), "hoàn");
1328        assert_eq!(t("hoanf"), "hoàn");
1329        // open diphthong oa/oe/uy: always 2nd vowel
1330        // òa typed as ofa (tone between vowels), oà typed as oaf (tone after)
1331        assert_eq!(t("oaf"), "oà"); // tone after all vowels → 2nd
1332        assert_eq!(t("ofa"), "òa"); // tone between vowels → 1st
1333        assert_eq!(tt("oaf"), "òa"); // traditional: tone on 1st vowel
1334        assert_eq!(tt("ofa"), "òa"); // tone between vowels → 1st (typing order)
1335    }
1336
1337    // ── TeipVni (combined Telex + VNI) tests ─────────────────────────
1338    fn tv(s: &str) -> String {
1339        convert_teip_vni(s, false, true)
1340    }
1341
1342    #[test]
1343    fn teipvni_telex_words() {
1344        assert_eq!(tv("vieejt"), "việt");
1345        assert_eq!(tv("dd"), "đ");
1346        assert_eq!(tv("aa"), "â");
1347        assert_eq!(tv("aw"), "ă");
1348        assert_eq!(tv("ee"), "ê");
1349        assert_eq!(tv("oo"), "ô");
1350        assert_eq!(tv("ow"), "ơ");
1351        assert_eq!(tv("uw"), "ư");
1352        assert_eq!(tv("uow"), "ươ");
1353        assert_eq!(tv("w"), "ư");
1354    }
1355    #[test]
1356    fn teipvni_vni_marks() {
1357        assert_eq!(tv("a6"), "â");
1358        assert_eq!(tv("a8"), "ă");
1359        assert_eq!(tv("e6"), "ê");
1360        assert_eq!(tv("o6"), "ô");
1361        assert_eq!(tv("o7"), "ơ");
1362        assert_eq!(tv("u7"), "ư");
1363        assert_eq!(tv("uo7"), "ươ");
1364        assert_eq!(tv("d9"), "đ");
1365    }
1366    #[test]
1367    fn teipvni_numbers() {
1368        // VNI tones
1369        assert_eq!(tv("qua1"), "quá");
1370        assert_eq!(tv("gia1"), "giá");
1371        assert_eq!(tv("vie65t"), "việt");
1372        assert_eq!(tv("tie61ng"), "tiếng");
1373    }
1374    #[test]
1375    fn teipvni_mixed() {
1376        // Mix Telex and VNI in same stream
1377        assert_eq!(tv("vie6t"), "viêt"); // VNI circumflex + Telex
1378        assert_eq!(tv("vie6ts"), "viết"); // VNI circumflex + Telex tone
1379        assert_eq!(tv("tie6ngs"), "tiếng");
1380    }
1381
1382    // ── VIQR tests ──────────────────────────────────────────────────
1383    fn viqr(s: &str) -> String {
1384        convert_viqr(s)
1385    }
1386
1387    #[test]
1388    fn viqr_marks() {
1389        assert_eq!(viqr("a^"), "â");
1390        assert_eq!(viqr("a("), "ă");
1391        assert_eq!(viqr("e^"), "ê");
1392        assert_eq!(viqr("o^"), "ô");
1393        assert_eq!(viqr("o+"), "ơ");
1394        assert_eq!(viqr("u+"), "ư");
1395        assert_eq!(viqr("dd"), "đ");
1396    }
1397    #[test]
1398    fn viqr_tones() {
1399        assert_eq!(viqr("a'"), "á");
1400        assert_eq!(viqr("a`"), "à");
1401        assert_eq!(viqr("a?"), "ả");
1402        assert_eq!(viqr("a~"), "ã");
1403        assert_eq!(viqr("a."), "ạ");
1404    }
1405    #[test]
1406    fn viqr_words() {
1407        assert_eq!(viqr("vie^'t"), "viết");
1408        assert_eq!(viqr("nu+o+'c"), "nước");
1409        assert_eq!(viqr("ngu+o+`i"), "người");
1410    }
1411    #[test]
1412    fn viqr_dd() {
1413        assert_eq!(viqr("dd"), "đ");
1414        assert_eq!(viqr("DD"), "Đ");
1415    }
1416    #[test]
1417    fn viqr_english() {
1418        assert_eq!(viqr("hello"), "hello");
1419    }
1420}
1421
1422#[test]
1423fn full_parity() {
1424    // This test verifies full parity for all 73 reference cases.
1425    // traditional settings: modern=false (traditional tone placement), short_w=true
1426
1427    struct Case {
1428        input: &'static str,
1429        expected: &'static str,
1430        method: u8,
1431    }
1432    // method: 1=Telex, 2=VNI, 3=VIQR, 4=TeipVni
1433    let cases = [
1434        // ── Telex ──
1435        Case {
1436            input: "dd",
1437            expected: "đ",
1438            method: 1,
1439        },
1440        Case {
1441            input: "DD",
1442            expected: "Đ",
1443            method: 1,
1444        },
1445        Case {
1446            input: "Dd",
1447            expected: "Đ",
1448            method: 1,
1449        },
1450        Case {
1451            input: "dD",
1452            expected: "Đ",
1453            method: 1,
1454        },
1455        Case {
1456            input: "aa",
1457            expected: "â",
1458            method: 1,
1459        },
1460        Case {
1461            input: "aw",
1462            expected: "ă",
1463            method: 1,
1464        },
1465        Case {
1466            input: "ee",
1467            expected: "ê",
1468            method: 1,
1469        },
1470        Case {
1471            input: "ew",
1472            expected: "ew", // ew no longer → ê (use ee)
1473            method: 1,
1474        },
1475        Case {
1476            input: "oo",
1477            expected: "ô",
1478            method: 1,
1479        },
1480        Case {
1481            input: "ow",
1482            expected: "ơ",
1483            method: 1,
1484        },
1485        Case {
1486            input: "uw",
1487            expected: "ư",
1488            method: 1,
1489        },
1490        Case {
1491            input: "uow",
1492            expected: "ươ",
1493            method: 1,
1494        },
1495        Case {
1496            input: "w",
1497            expected: "ư",
1498            method: 1,
1499        },
1500        Case {
1501            input: "W",
1502            expected: "Ư",
1503            method: 1,
1504        },
1505        Case {
1506            input: "wn",
1507            expected: "ưn",
1508            method: 1,
1509        },
1510        Case {
1511            input: "ws",
1512            expected: "ứ",
1513            method: 1,
1514        },
1515        Case {
1516            input: "wf",
1517            expected: "ừ",
1518            method: 1,
1519        },
1520        Case {
1521            input: "aws",
1522            expected: "ắ",
1523            method: 1,
1524        },
1525        Case {
1526            input: "aas",
1527            expected: "ấ",
1528            method: 1,
1529        },
1530        Case {
1531            input: "ees",
1532            expected: "ế",
1533            method: 1,
1534        },
1535        Case {
1536            input: "oos",
1537            expected: "ố",
1538            method: 1,
1539        },
1540        Case {
1541            input: "ows",
1542            expected: "ớ",
1543            method: 1,
1544        },
1545        Case {
1546            input: "uws",
1547            expected: "ứ",
1548            method: 1,
1549        },
1550        Case {
1551            input: "chaof",
1552            expected: "chào",
1553            method: 1,
1554        },
1555        Case {
1556            input: "vieejt",
1557            expected: "việt",
1558            method: 1,
1559        },
1560        Case {
1561            input: "tieengs",
1562            expected: "tiếng",
1563            method: 1,
1564        },
1565        Case {
1566            input: "quas",
1567            expected: "quá",
1568            method: 1,
1569        },
1570        Case {
1571            input: "gias",
1572            expected: "giá",
1573            method: 1,
1574        },
1575        Case {
1576            input: "xin",
1577            expected: "xin",
1578            method: 1,
1579        },
1580        Case {
1581            input: "tooi",
1582            expected: "tôi",
1583            method: 1,
1584        },
1585        Case {
1586            input: "laf",
1587            expected: "là",
1588            method: 1,
1589        },
1590        Case {
1591            input: "cos",
1592            expected: "có",
1593            method: 1,
1594        },
1595        Case {
1596            input: "khoong",
1597            expected: "không",
1598            method: 1,
1599        },
1600        Case {
1601            input: "Vieetj",
1602            expected: "Việt",
1603            method: 1,
1604        },
1605        Case {
1606            input: "toans",
1607            expected: "toán",
1608            method: 1,
1609        },
1610        Case {
1611            input: "hoaf",
1612            expected: "hòa",
1613            method: 1,
1614        },
1615        Case {
1616            input: "thowif",
1617            expected: "thời",
1618            method: 1,
1619        },
1620        Case {
1621            input: "cuar",
1622            expected: "của",
1623            method: 1,
1624        },
1625        Case {
1626            input: "gif",
1627            expected: "gì",
1628            method: 1,
1629        },
1630        Case {
1631            input: "giowf",
1632            expected: "giờ",
1633            method: 1,
1634        },
1635        Case {
1636            input: "gioir",
1637            expected: "giỏi",
1638            method: 1,
1639        },
1640        Case {
1641            input: "cuowcs",
1642            expected: "cước",
1643            method: 1,
1644        },
1645        Case {
1646            input: "dduwowcj",
1647            expected: "được",
1648            method: 1,
1649        },
1650        Case {
1651            input: "nguwowif",
1652            expected: "người",
1653            method: 1,
1654        },
1655        Case {
1656            input: "asz",
1657            expected: "a",
1658            method: 1,
1659        },
1660        Case {
1661            input: "hello",
1662            expected: "hello",
1663            method: 1,
1664        },
1665        Case {
1666            input: "hoanf",
1667            expected: "hoàn",
1668            method: 1,
1669        },
1670        Case {
1671            input: "huyeenf",
1672            expected: "huyền",
1673            method: 1,
1674        },
1675        Case {
1676            input: "nguyeexn",
1677            expected: "nguyễn",
1678            method: 1,
1679        },
1680        Case {
1681            input: "ngoaif",
1682            expected: "ngoài",
1683            method: 1,
1684        },
1685        Case {
1686            input: "thuowngf",
1687            expected: "thường",
1688            method: 1,
1689        },
1690        // ── VNI ──
1691        Case {
1692            input: "d9",
1693            expected: "đ",
1694            method: 2,
1695        },
1696        Case {
1697            input: "a6",
1698            expected: "â",
1699            method: 2,
1700        },
1701        Case {
1702            input: "a8",
1703            expected: "ă",
1704            method: 2,
1705        },
1706        Case {
1707            input: "e6",
1708            expected: "ê",
1709            method: 2,
1710        },
1711        Case {
1712            input: "o6",
1713            expected: "ô",
1714            method: 2,
1715        },
1716        Case {
1717            input: "o7",
1718            expected: "ơ",
1719            method: 2,
1720        },
1721        Case {
1722            input: "u7",
1723            expected: "ư",
1724            method: 2,
1725        },
1726        Case {
1727            input: "uo7",
1728            expected: "ươ",
1729            method: 2,
1730        },
1731        Case {
1732            input: "vie65t",
1733            expected: "việt",
1734            method: 2,
1735        },
1736        Case {
1737            input: "tie61ng",
1738            expected: "tiếng",
1739            method: 2,
1740        },
1741        Case {
1742            input: "qua1",
1743            expected: "quá",
1744            method: 2,
1745        },
1746        Case {
1747            input: "gia1",
1748            expected: "giá",
1749            method: 2,
1750        },
1751        // ── TeipVni ──
1752        Case {
1753            input: "vie61t",
1754            expected: "viết",
1755            method: 4,
1756        },
1757        Case {
1758            input: "vieejt",
1759            expected: "việt",
1760            method: 4,
1761        },
1762        Case {
1763            input: "tie61ng",
1764            expected: "tiếng",
1765            method: 4,
1766        },
1767        Case {
1768            input: "a6",
1769            expected: "â",
1770            method: 4,
1771        },
1772        Case {
1773            input: "dd",
1774            expected: "đ",
1775            method: 4,
1776        },
1777        // ── VIQR ──
1778        Case {
1779            input: "dd",
1780            expected: "đ",
1781            method: 3,
1782        },
1783        Case {
1784            input: "DD",
1785            expected: "Đ",
1786            method: 3,
1787        },
1788        Case {
1789            input: "vie^'t",
1790            expected: "viết",
1791            method: 3,
1792        },
1793        Case {
1794            input: "nu+o+'c",
1795            expected: "nước",
1796            method: 3,
1797        },
1798        Case {
1799            input: "ngu+o+`i",
1800            expected: "người",
1801            method: 3,
1802        },
1803    ];
1804
1805    let mut failures = Vec::new();
1806    for c in &cases {
1807        let result = match c.method {
1808            1 => convert_telex(c.input, false, true),
1809            2 => convert_vni(c.input, false, true),
1810            3 => convert_viqr(c.input),
1811            4 => convert_teip_vni(c.input, false, true),
1812            _ => unreachable!(),
1813        };
1814        if result != c.expected {
1815            failures.push(format!(
1816                "[m{}] '{}' -> '{}' (expected '{}')",
1817                c.method, c.input, result, c.expected
1818            ));
1819        }
1820    }
1821    assert!(
1822        failures.is_empty(),
1823        "{} full parity failures:\n{}",
1824        failures.len(),
1825        failures.join("\n")
1826    );
1827}
1828
1829#[test]
1830fn comprehensive_vietnamese() {
1831    // Comprehensive test covering all Vietnamese syllable patterns.
1832    // Uses traditional settings: modern=false, short_w=true.
1833    // method: 1=Telex, 2=VNI, 3=VIQR, 4=TeipVni.
1834    // Method 0 = Telex (traditional) for brevity.
1835
1836    let cases: &[(&str, &str, u8)] = &[
1837        // ═══ SINGLE VOWELS — 5 tones each ═══
1838        ("as", "á", 1),
1839        ("af", "à", 1),
1840        ("ar", "ả", 1),
1841        ("ax", "ã", 1),
1842        ("aj", "ạ", 1),
1843        ("es", "é", 1),
1844        ("ef", "è", 1),
1845        ("er", "ẻ", 1),
1846        ("ex", "ẽ", 1),
1847        ("ej", "ẹ", 1),
1848        ("is", "í", 1),
1849        ("if", "ì", 1),
1850        ("ir", "ỉ", 1),
1851        ("ix", "ĩ", 1),
1852        ("ij", "ị", 1),
1853        ("os", "ó", 1),
1854        ("of", "ò", 1),
1855        ("or", "ỏ", 1),
1856        ("ox", "õ", 1),
1857        ("oj", "ọ", 1),
1858        ("us", "ú", 1),
1859        ("uf", "ù", 1),
1860        ("ur", "ủ", 1),
1861        ("ux", "ũ", 1),
1862        ("uj", "ụ", 1),
1863        ("ys", "ý", 1),
1864        ("yf", "ỳ", 1),
1865        ("yr", "ỷ", 1),
1866        ("yx", "ỹ", 1),
1867        ("yj", "ỵ", 1),
1868        // ═══ MARKED VOWELS (breve/circumflex/horn) + tones ═══
1869        // ă = aw
1870        ("aw", "ă", 1),
1871        ("aws", "ắ", 1),
1872        ("awf", "ằ", 1),
1873        ("awr", "ẳ", 1),
1874        ("awx", "ẵ", 1),
1875        ("awj", "ặ", 1),
1876        // â = aa
1877        ("aa", "â", 1),
1878        ("aas", "ấ", 1),
1879        ("aaf", "ầ", 1),
1880        ("aar", "ẩ", 1),
1881        ("aax", "ẫ", 1),
1882        ("aaj", "ậ", 1),
1883        // ê = ee / ew
1884        ("ee", "ê", 1),
1885        ("ees", "ế", 1),
1886        ("eef", "ề", 1),
1887        ("eer", "ể", 1),
1888        ("eex", "ễ", 1),
1889        ("eej", "ệ", 1),
1890        // ew no longer → ê (use ee for circumflex)
1891        // tone keys still apply: e+w+s → éw (tone on e, w literal)
1892        ("ew", "ew", 1),
1893        ("ews", "éw", 1),
1894        ("ewf", "èw", 1),
1895        ("ewr", "ẻw", 1),
1896        // ô = oo
1897        ("oo", "ô", 1),
1898        ("oos", "ố", 1),
1899        ("oof", "ồ", 1),
1900        ("oor", "ổ", 1),
1901        ("oox", "ỗ", 1),
1902        ("ooj", "ộ", 1),
1903        // ơ = ow
1904        ("ow", "ơ", 1),
1905        ("ows", "ớ", 1),
1906        ("owf", "ờ", 1),
1907        ("owr", "ở", 1),
1908        ("owx", "ỡ", 1),
1909        ("owj", "ợ", 1),
1910        // ư = uw
1911        ("uw", "ư", 1),
1912        ("uws", "ứ", 1),
1913        ("uwf", "ừ", 1),
1914        ("uwr", "ử", 1),
1915        ("uwx", "ữ", 1),
1916        ("uwj", "ự", 1),
1917        // ═══ dd → đ ═══
1918        ("dd", "đ", 1),
1919        // ═══ STANDALONE w → ư ═══
1920        ("w", "ư", 1),
1921        ("ws", "ứ", 1),
1922        ("wf", "ừ", 1),
1923        ("wr", "ử", 1),
1924        ("wx", "ữ", 1),
1925        ("wj", "ự", 1),
1926        ("wn", "ưn", 1),
1927        ("wt", "ưt", 1),
1928        // ═══ DIPHTHONGS — closed (trailing consonant) ═══
1929        // ai, ao, au
1930        ("ais", "ái", 1),
1931        ("aif", "ài", 1),
1932        ("air", "ải", 1),
1933        ("aix", "ãi", 1),
1934        ("aij", "ại", 1),
1935        ("aos", "áo", 1),
1936        ("aof", "ào", 1),
1937        ("aor", "ảo", 1),
1938        ("aox", "ão", 1),
1939        ("aoj", "ạo", 1),
1940        ("aus", "áu", 1),
1941        ("auf", "àu", 1),
1942        ("aur", "ảu", 1),
1943        ("aux", "ãu", 1),
1944        ("auj", "ạu", 1),
1945        // ây, ay
1946        ("aays", "ấy", 1),
1947        ("aayf", "ầy", 1),
1948        ("aayr", "ẩy", 1),
1949        ("aayx", "ẫy", 1),
1950        ("aayj", "ậy", 1),
1951        ("ays", "áy", 1),
1952        ("ayf", "ày", 1),
1953        ("ayr", "ảy", 1),
1954        ("ayx", "ãy", 1),
1955        ("ayj", "ạy", 1),
1956        // eo, êu
1957        ("eos", "éo", 1),
1958        ("eof", "èo", 1),
1959        ("eor", "ẻo", 1),
1960        ("eox", "ẽo", 1),
1961        ("eoj", "ẹo", 1),
1962        ("eeus", "ếu", 1),
1963        ("eeuf", "ều", 1),
1964        ("eeur", "ểu", 1),
1965        ("eeux", "ễu", 1),
1966        ("eeuj", "ệu", 1),
1967        // oi, ôi, ơi, ui, ưi, ưu
1968        ("ois", "ói", 1),
1969        ("oif", "òi", 1),
1970        ("oir", "ỏi", 1),
1971        ("oix", "õi", 1),
1972        ("oij", "ọi", 1),
1973        ("oois", "ối", 1),
1974        ("ooif", "ồi", 1),
1975        ("ooir", "ổi", 1),
1976        ("ooix", "ỗi", 1),
1977        ("ooij", "ội", 1),
1978        ("owis", "ới", 1),
1979        ("owif", "ời", 1),
1980        ("owir", "ởi", 1),
1981        ("owix", "ỡi", 1),
1982        ("owij", "ợi", 1),
1983        ("uis", "úi", 1),
1984        ("uif", "ùi", 1),
1985        ("uir", "ủi", 1),
1986        ("uix", "ũi", 1),
1987        ("uij", "ụi", 1),
1988        ("uwis", "ứi", 1),
1989        ("uwif", "ừi", 1),
1990        ("uwir", "ửi", 1),
1991        ("uwix", "ữi", 1),
1992        ("uwij", "ựi", 1),
1993        ("uwus", "ứu", 1),
1994        ("uwuf", "ừu", 1),
1995        ("uwur", "ửu", 1),
1996        ("uwux", "ữu", 1),
1997        ("uwuj", "ựu", 1),
1998        // ═══ oa, oe, uy — closed (trailing consonant, tone on 2nd vowel) ═══
1999        ("oans", "oán", 1),
2000        ("oanf", "oàn", 1),
2001        ("oanr", "oản", 1),
2002        ("oanx", "oãn", 1),
2003        ("oanj", "oạn", 1),
2004        ("oats", "oát", 1),
2005        ("oatf", "oàt", 1),
2006        ("oatr", "oảt", 1),
2007        ("oens", "oén", 1),
2008        ("oenf", "oèn", 1),
2009        ("uyns", "uýn", 1),
2010        ("uynf", "uỳn", 1),
2011        ("uynr", "uỷn", 1),
2012        // ═══ oa, oe, uy — open (traditional: tone on 1st vowel) ═══
2013        ("oas", "óa", 1),
2014        ("oaf", "òa", 1),
2015        ("oar", "ỏa", 1),
2016        ("oax", "õa", 1),
2017        ("oaj", "ọa", 1),
2018        ("oes", "óe", 1),
2019        ("oef", "òe", 1),
2020        ("uys", "úy", 1),
2021        ("uyf", "ùy", 1),
2022        ("uyr", "ủy", 1),
2023        ("uyx", "ũy", 1),
2024        ("uyj", "ụy", 1),
2025        // ═══ TRIPHTHONGS ═══
2026        // oai, oay (tone on middle vowel)
2027        ("oais", "oái", 1),
2028        ("oaif", "oài", 1),
2029        ("oair", "oải", 1),
2030        ("oaix", "oãi", 1),
2031        ("oaij", "oại", 1),
2032        ("oays", "oáy", 1),
2033        ("oayf", "oày", 1),
2034        ("oayr", "oảy", 1),
2035        ("oayx", "oãy", 1),
2036        ("oayj", "oạy", 1),
2037        // uây (tone on middle vowel â)
2038        ("uaays", "uấy", 1),
2039        ("uaayf", "uầy", 1),
2040        ("uaayr", "uẩy", 1),
2041        ("uaayx", "uẫy", 1),
2042        ("uaayj", "uậy", 1),
2043        // uyê (tone on ê — 3rd vowel, the one with variant)
2044        ("uyeef", "uyề", 1),
2045        ("uyees", "uyế", 1),
2046        ("uyeer", "uyể", 1),
2047        ("uyeex", "uyễ", 1),
2048        ("uyeej", "uyệ", 1),
2049        // iê, yê
2050        ("ieef", "iề", 1),
2051        ("iees", "iế", 1),
2052        ("ieer", "iể", 1),
2053        ("ieex", "iễ", 1),
2054        ("ieej", "iệ", 1),
2055        ("yeef", "yề", 1),
2056        ("yees", "yế", 1),
2057        ("yeer", "yể", 1),
2058        ("yeex", "yễ", 1),
2059        ("yeej", "yệ", 1),
2060        // uôi = u + ô + i (oo→ô, tone on ô which has variant≠0)
2061        ("uoois", "uối", 1),
2062        ("uooif", "uồi", 1),
2063        ("uooir", "uổi", 1),
2064        ("uooix", "uỗi", 1),
2065        ("uooij", "uội", 1),
2066        // ươi = u + ơ + i (w targets o, uow spread → ươ, tone on ơ which has variant≠0)
2067        ("uoiws", "ưới", 1),
2068        ("uoiwf", "ười", 1),
2069        ("uoiwr", "ưởi", 1),
2070        ("uoiwx", "ưỡi", 1),
2071        ("uoiwj", "ượi", 1),
2072        // ═══ GI / QU initial consonant digraphs ═══
2073        ("gif", "gì", 1),
2074        ("gis", "gí", 1),
2075        ("gir", "gỉ", 1),
2076        ("gix", "gĩ", 1),
2077        ("gij", "gị", 1),
2078        ("gias", "giá", 1),
2079        ("giaf", "già", 1),
2080        ("giar", "giả", 1),
2081        ("giax", "giã", 1),
2082        ("giaj", "giạ", 1),
2083        ("gioir", "giỏi", 1),
2084        ("gioif", "giòi", 1),
2085        ("giois", "giói", 1),
2086        ("quas", "quá", 1),
2087        ("quaf", "quà", 1),
2088        ("quar", "quả", 1),
2089        ("quax", "quã", 1),
2090        ("quaj", "quạ", 1),
2091        ("quys", "quý", 1),
2092        ("quyf", "quỳ", 1),
2093        ("quyr", "quỷ", 1),
2094        ("quyx", "quỹ", 1),
2095        ("quyj", "quỵ", 1),
2096        // ═══ REAL WORDS ═══
2097        ("chaof", "chào", 1),
2098        ("vieejt", "việt", 1),
2099        ("tieengs", "tiếng", 1),
2100        ("khoong", "không", 1),
2101        ("cuar", "của", 1),
2102        ("cuowcs", "cước", 1),
2103        ("dduwowcj", "được", 1),
2104        ("nguwowif", "người", 1),
2105        ("thuowngf", "thường", 1),
2106        ("hoanf", "hoàn", 1),
2107        ("huyeenf", "huyền", 1),
2108        ("nguyeexn", "nguyễn", 1),
2109        ("ngoaif", "ngoài", 1),
2110        ("ngoafi", "ngoài", 1),
2111        ("vaya", "vây", 1),
2112        ("vayaj", "vậy", 1),
2113        ("vayas", "vấy", 1),
2114        ("mayas", "mấy", 1),
2115        // Double-vowel across consonant: nhan+a→nhân, khan+a→khân
2116        ("nhana", "nhân", 1),
2117        ("khanas", "khấn", 1),
2118        ("nhanaa", "nhana", 1),
2119        ("toans", "toán", 1),
2120        ("thowif", "thời", 1),
2121        ("xin", "xin", 1),
2122        ("tooi", "tôi", 1),
2123        ("laf", "là", 1),
2124        ("cos", "có", 1),
2125        ("banj", "bạn", 1),
2126        ("minhf", "mình", 1),
2127        ("nawm", "năm", 1),
2128        ("thangs", "tháng", 1),
2129        ("truwowngf", "trường", 1),
2130        ("luaatj", "luật", 1),
2131        // ═══ dd ACROSS CHARACTERS (Unikey-compatible) ═══
2132        ("dad", "đa", 1),
2133        ("DAD", "ĐA", 1),
2134        ("Dad", "Đa", 1),
2135        ("dda", "đa", 1),
2136        ("dadf", "đà", 1),
2137        ("dads", "đá", 1),
2138        ("dadr", "đả", 1),
2139        ("dadx", "đã", 1),
2140        ("dadj", "đạ", 1),
2141        // Toggle only for non-consecutive d's; at most one đ
2142        ("dadd", "dad", 1),
2143        // Consecutive d's after first đ: all literal
2144        ("ddd", "dd", 1),
2145        ("dddd", "ddd", 1),
2146        // ═══ CONSONANT CLUSTERS ═══
2147        ("nhas", "nhá", 1),
2148        ("nghir", "nghỉ", 1),
2149        ("gheps", "ghép", 1),
2150        ("truws", "trứ", 1),
2151        ("thas", "thá", 1),
2152        ("phos", "phó", 1),
2153        ("khas", "khá", 1),
2154        ("chays", "cháy", 1),
2155        ("ngays", "ngáy", 1),
2156        // ═══ FINAL CONSONANT VARIETY ═══
2157        ("ddejp", "đẹp", 1),
2158        ("maats", "mất", 1),
2159        ("bacs", "bác", 1),
2160        ("ngachs", "ngách", 1),
2161        ("banhf", "bành", 1),
2162        ("tins", "tín", 1),
2163        ("metj", "mẹt", 1),
2164        // ═══ z (tone removal) ═══
2165        ("asz", "a", 1),
2166        ("awsjz", "ă", 1),
2167        ("aasz", "â", 1),
2168        // ═══ ENGLISH PASS-THROUGH (words without Telex tone/mark keys s/f/r/x/j/w/z) ═══
2169        ("hello", "hello", 1),
2170        ("bacon", "bacon", 1),
2171        ("thing", "thing", 1),
2172        ("climb", "climb", 1),
2173        // ═══ CAPITALIZATION ═══
2174        ("As", "Á", 1),
2175        ("Af", "À", 1),
2176        ("Aas", "Ấ", 1),
2177        ("Aws", "Ắ", 1),
2178        ("Dd", "Đ", 1),
2179        ("DD", "Đ", 1),
2180        ("W", "Ư", 1),
2181        ("Ws", "Ứ", 1),
2182        ("Vieetj", "Việt", 1),
2183        ("Tooi", "Tôi", 1),
2184        // ═══ VNI method ═══
2185        ("d9", "đ", 2),
2186        ("a6", "â", 2),
2187        ("a8", "ă", 2),
2188        ("e6", "ê", 2),
2189        ("o6", "ô", 2),
2190        ("o7", "ơ", 2),
2191        ("u7", "ư", 2),
2192        ("uo7", "ươ", 2),
2193        ("vie65t", "việt", 2),
2194        ("tie61ng", "tiếng", 2),
2195        ("qua1", "quá", 2),
2196        ("gia1", "giá", 2),
2197        // ═══ TeipVni combined ═══
2198        ("vie61t", "viết", 4),
2199        ("vieejt", "việt", 4),
2200        ("tie61ng", "tiếng", 4),
2201        ("a6", "â", 4),
2202        ("dd", "đ", 4),
2203        // ═══ VIQR method ═══
2204        ("dd", "đ", 3),
2205        ("DD", "Đ", 3),
2206        ("vie^'t", "viết", 3),
2207        ("nu+o+'c", "nước", 3),
2208        ("ngu+o+`i", "người", 3),
2209    ];
2210
2211    let mut failures = Vec::new();
2212    for &(input, expected, method) in cases {
2213        let result = match method {
2214            1 => convert_telex(input, false, true),
2215            2 => convert_vni(input, false, true),
2216            3 => convert_viqr(input),
2217            4 => convert_teip_vni(input, false, true),
2218            _ => unreachable!(),
2219        };
2220        if result != expected {
2221            failures.push(format!(
2222                "[m{}] '{}' -> '{}' (expected '{}')",
2223                method, input, result, expected
2224            ));
2225        }
2226    }
2227    assert!(
2228        failures.is_empty(),
2229        "{} comprehensive failures (traditional mode):\n{}",
2230        failures.len(),
2231        failures.join("\n")
2232    );
2233}