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, variant: u8, tone: u8, is_dstroke: bool, upper: bool,
11}
12
13impl Letter {
14    fn new(c: char, upper: bool) -> Self {
15        let lc = c.to_ascii_lowercase();
16        Letter { c: lc, is_vowel: tables::is_vowel(lc), variant: 0, tone: 0, is_dstroke: false, upper }
17    }
18}
19
20fn emit(ls: &[Letter]) -> String {
21    let mut out = String::with_capacity(ls.len() * 3);
22    for lt in ls {
23        if lt.is_dstroke {
24            out.push_str(if lt.upper { tables::D_STROKE_UP } else { tables::D_STROKE_LO });
25        } else if lt.is_vowel {
26            out.push_str(tables::vowel_utf8(lt.c, lt.variant, lt.tone, lt.upper));
27        } else {
28            out.push(if lt.upper { lt.c.to_ascii_uppercase() } else { lt.c });
29        }
30    }
31    out
32}
33
34fn last_vowel_index(ls: &[Letter]) -> Option<usize> { ls.iter().rposition(|lt| lt.is_vowel) }
35fn last_d_index(ls: &[Letter]) -> Option<usize> {
36    ls.iter().rposition(|lt| lt.c == 'd' && !lt.is_vowel && !lt.is_dstroke)
37}
38
39/// Find the last vowel that can be modified by `w`: a→ă, o→ơ, u→ư, e→ê.
40/// Used by the `w` key handler so that e.g. "toi" + w finds 'o' (not 'i').
41fn last_w_target_index(ls: &[Letter]) -> Option<usize> {
42    ls.iter().rposition(|lt| lt.is_vowel && matches!(lt.c, 'a' | 'o' | 'u' | 'e'))
43}
44
45fn tone_vowel_index(ls: &[Letter], _modern: bool) -> Option<usize> {
46    let end = last_vowel_index(ls)?;
47    let mut start = end;
48    while start > 0 && ls[start - 1].is_vowel { start -= 1; }
49    if start < end {
50        if ls[start].c == 'u' && start > 0 && ls[start - 1].c == 'q' { start += 1; }
51        else if ls[start].c == 'i' && start > 0 && ls[start - 1].c == 'g' { start += 1; }
52    }
53    if start > end { start = end; }
54    for i in (start..=end).rev() { if ls[i].variant != 0 { return Some(i); } }
55    let count = end - start + 1;
56    if count == 1 { return Some(start); }
57    if count >= 3 {
58        if ls[start].c == 'u' && start + 2 <= end && ls[start + 1].c == 'y' && ls[start + 2].c == 'e'
59        { return Some(start + 2); }
60        return Some(start + 1);
61    }
62    if end + 1 < ls.len() { return Some(end); }
63    let (c1, c2) = (ls[start].c, ls[end].c);
64    if (c1 == 'o' && c2 == 'a') || (c1 == 'o' && c2 == 'e') || (c1 == 'u' && c2 == 'y') {
65        return Some(end); // always 2nd vowel — use typing order for òa (ofa) vs oà (oaf)
66    }
67    Some(start) // default open diphthong: tone on first vowel
68}
69
70fn telex_tone(c: char) -> Option<u8> {
71    match c.to_ascii_lowercase() { 's'=>Some(2),'f'=>Some(1),'r'=>Some(3),'x'=>Some(4),'j'=>Some(5), _=>None }
72}
73fn vni_tone(c: char) -> Option<u8> {
74    match c { '1'=>Some(2),'2'=>Some(1),'3'=>Some(3),'4'=>Some(4),'5'=>Some(5), _=>None }
75}
76
77pub fn convert_telex(input: &str, modern: bool, short_w: bool) -> String {
78    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
79    for ch in input.chars() {
80        let lc = ch.to_ascii_lowercase(); let upper = ch.is_ascii_uppercase(); let n = ls.len();
81        if lc == 'd' && n > 0 && ls[n-1].c == 'd' && !ls[n-1].is_vowel && !ls[n-1].is_dstroke
82        { ls[n-1].is_dstroke = true; if upper { ls[n-1].upper = true; } continue; }
83        // Double-vowel circumflex: search backwards through vowel cluster.
84        // Toggle behavior (Unikey): oo→ô, ooo→oo (3rd press undoes circumflex)
85        // Double-vowel circumflex: search backwards through vowel cluster.
86        // Toggle behavior (Unikey): oo→ô, ooo→oo (3rd press undoes circumflex)
87        if tables::is_vowel(lc) && matches!(lc, 'a'|'e'|'o') && n > 0 {
88            let mut consumed = false;
89            if let Some(vi) = last_vowel_index(&ls) {
90                for i in (0..=vi).rev() {
91                    if ls[i].is_vowel {
92                        if ls[i].c == lc {
93                            if ls[i].variant == 0 { ls[i].variant = 1; if upper { ls[i].upper = true; } consumed = true; break; }
94                            if ls[i].variant == 1 { ls[i].variant = 0; break; } // toggle off, then push copy
95                        }
96                    } else { break; }
97                }
98            }
99            if consumed { continue; }
100        }
101        if lc == 'w' {
102            if let Some(vi) = last_w_target_index(&ls) {
103                match ls[vi].c {
104                    'a' => { ls[vi].variant = 2; continue; }
105                    'o' => { ls[vi].variant = 2; if vi > 0 && ls[vi-1].c == 'u' { ls[vi-1].variant = 2; } continue; }
106                    'u' => { ls[vi].variant = 2; continue; }
107                    'e' => { ls[vi].variant = 1; continue; }
108                    _ => {}
109                }
110            } else if short_w {
111                // short_w mode: standalone w → ư
112                ls.push(Letter { c: 'u', is_vowel: true, variant: 2, tone: 0, is_dstroke: false, upper });
113                continue;
114            }
115        }
116        if let Some(t) = telex_tone(lc) {
117            if let Some(vi) = tone_vowel_index(&ls, modern) {
118                ls[vi].tone = t; continue; // replace existing tone
119            }
120        }
121        if lc == 'z' {
122            if let Some(vi) = tone_vowel_index(&ls, modern) {
123                if ls[vi].tone != 0 { ls[vi].tone = 0; continue; }
124            }
125        }
126        ls.push(Letter::new(ch, upper));
127    }
128    emit(&ls)
129}
130
131/// Combined Telex + VNI mode.
132/// Accepts BOTH Telex and VNI keystrokes in one stream:
133///   - VNI digits (0-9) apply VNI tone/marks to the last vowel
134///   - Telex markers (w, s/f/r/x/j/z, and double-vowel ^/breve) apply Telex
135///   - everything else is a normal letter
136pub fn convert_teip_vni(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(); let upper = ch.is_ascii_uppercase(); let n = ls.len();
140
141        // ── VNI digit rules ──
142        if let Some(t) = vni_tone(lc) {
143            if let Some(vi) = tone_vowel_index(&ls, modern) { ls[vi].tone = t; continue; }
144        }
145        if lc >= '0' && lc <= '9' {
146            match lc {
147                '6' => { if let Some(vi) = last_vowel_index(&ls) { if matches!(ls[vi].c, 'a'|'e'|'o') { ls[vi].variant = 1; continue; } } }
148                '7' => { if let Some(vi) = last_vowel_index(&ls) { if matches!(ls[vi].c, 'o'|'u') { ls[vi].variant = 2; if ls[vi].c == 'o' && vi > 0 && ls[vi-1].c == 'u' { ls[vi-1].variant = 2; } continue; } } }
149                '8' => { if let Some(vi) = last_vowel_index(&ls) { if ls[vi].c == 'a' { ls[vi].variant = 2; continue; } } }
150                '9' => { if let Some(di) = last_d_index(&ls) { ls[di].is_dstroke = true; if upper { ls[di].upper = true; } } continue; }
151                '0' => { continue; }
152                _ => {} // digits 1-5 already handled by vni_tone above
153            }
154        }
155
156        // ── Telex dd → đ ──
157        if lc == 'd' && n > 0 && ls[n-1].c == 'd' && !ls[n-1].is_vowel && !ls[n-1].is_dstroke
158        { ls[n-1].is_dstroke = true; if upper { ls[n-1].upper = true; } continue; }
159
160        // ── Telex circumflex: double same vowel ──
161        // Search backwards from last vowel through vowel cluster.
162        // Toggle behavior (Unikey): oo→ô, ooo→oo (3rd press undoes circumflex)
163        if tables::is_vowel(lc) && matches!(lc, 'a'|'e'|'o') && n > 0 {
164            let mut consumed = false;
165            if let Some(vi) = last_vowel_index(&ls) {
166                for i in (0..=vi).rev() {
167                    if ls[i].is_vowel {
168                        if ls[i].c == lc {
169                            if ls[i].variant == 0 { ls[i].variant = 1; if upper { ls[i].upper = true; } consumed = true; break; }
170                            if ls[i].variant == 1 { ls[i].variant = 0; break; } // toggle off, then push copy
171                        }
172                    } else { break; }
173                }
174            }
175            if consumed { continue; }
176        }
177
178        // ── Telex w (breve/horn) ──
179        if lc == 'w' {
180            if let Some(vi) = last_w_target_index(&ls) {
181                match ls[vi].c {
182                    'a' => { ls[vi].variant = 2; continue; }
183                    'o' => { ls[vi].variant = 2; if vi > 0 && ls[vi-1].c == 'u' { ls[vi-1].variant = 2; } continue; }
184                    'u' => { ls[vi].variant = 2; continue; }
185                    'e' => { ls[vi].variant = 1; continue; }
186                    _ => {}
187                }
188            } else if short_w {
189                ls.push(Letter { c: 'u', is_vowel: true, variant: 2, tone: 0, is_dstroke: false, upper });
190                continue;
191            }
192        }
193
194        // ── Telex tone marks ──
195        if let Some(t) = telex_tone(lc) {
196            if let Some(vi) = tone_vowel_index(&ls, modern) {
197                ls[vi].tone = t; continue; // replace existing tone
198            }
199        }
200        if lc == 'z' {
201            if let Some(vi) = tone_vowel_index(&ls, modern) {
202                if ls[vi].tone != 0 { ls[vi].tone = 0; continue; }
203            }
204        }
205
206        ls.push(Letter::new(ch, upper));
207    }
208    emit(&ls)
209}
210
211/// VIQR (Vietnamese Quoted-Readable) conversion.
212///
213/// Uses ASCII punctuation as diacritic markers:
214///   ^ circumflex, ( breve, + horn (modifiers)
215///   ' sac, ` huyen, ? hoi, ~ nga, . nang (tones)
216///   dd -> đ d-stroke
217pub fn convert_viqr(input: &str) -> String {
218    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
219    for ch in input.chars() {
220        let lc = ch.to_ascii_lowercase(); let n = ls.len();
221
222        // Modifier characters: apply to last vowel
223        match ch {
224            '^' | '(' | '+' => {
225                if let Some(vi) = last_vowel_index(&ls) {
226                    match (ch, ls[vi].c) {
227                        ('^', 'a' | 'e' | 'o') => { ls[vi].variant = 1; continue; }
228                        ('(', 'a') => { ls[vi].variant = 2; continue; }
229                        ('+', 'o' | 'u') => { ls[vi].variant = 2; continue; }
230                        _ => {} // illegal combination: fall through to literal
231                    }
232                }
233                // Fall through: treat as literal character
234                ls.push(Letter { c: ch, is_vowel: false, variant: 0, tone: 0, is_dstroke: false, upper: false });
235                continue;
236            }
237            _ => {}
238        }
239
240        // Tone characters
241        match ch {
242            '\'' => { if let Some(vi) = last_vowel_index(&ls) { ls[vi].tone = 2; continue; } }
243            '`'  => { if let Some(vi) = last_vowel_index(&ls) { ls[vi].tone = 1; continue; } }
244            '?'  => { if let Some(vi) = last_vowel_index(&ls) { ls[vi].tone = 3; continue; } }
245            '~'  => { if let Some(vi) = last_vowel_index(&ls) { ls[vi].tone = 4; continue; } }
246            '.'  => { if let Some(vi) = last_vowel_index(&ls) { ls[vi].tone = 5; continue; } }
247            _ => {}
248        }
249
250        // dd → đ
251        if lc == 'd' && n > 0 && ls[n-1].c == 'd' && !ls[n-1].is_vowel && !ls[n-1].is_dstroke {
252            ls[n-1].is_dstroke = true;
253            if ch.is_ascii_uppercase() { ls[n-1].upper = true; }
254            continue;
255        }
256
257        ls.push(Letter::new(ch, ch.is_ascii_uppercase()));
258    }
259    emit(&ls)
260}
261
262pub fn convert_vni(input: &str, modern: bool, _short_w: bool) -> String {
263    let mut ls: Vec<Letter> = Vec::with_capacity(input.len());
264    for ch in input.chars() {
265        let lc = ch.to_ascii_lowercase(); let upper = ch.is_ascii_uppercase();
266        if let Some(t) = vni_tone(lc) {
267            if let Some(vi) = tone_vowel_index(&ls, modern) { ls[vi].tone = t; continue; }
268        }
269        match lc {
270            '6' => { if let Some(vi) = last_vowel_index(&ls) { if matches!(ls[vi].c, 'a'|'e'|'o') { ls[vi].variant = 1; continue; } } }
271            '7' => { if let Some(vi) = last_vowel_index(&ls) { if matches!(ls[vi].c, 'o'|'u') { ls[vi].variant = 2; if ls[vi].c == 'o' && vi > 0 && ls[vi-1].c == 'u' { ls[vi-1].variant = 2; } continue; } } }
272            '8' => { if let Some(vi) = last_vowel_index(&ls) { if ls[vi].c == 'a' { ls[vi].variant = 2; continue; } } }
273            '9' => { if let Some(di) = last_d_index(&ls) { ls[di].is_dstroke = true; if upper { ls[di].upper = true; } } continue; }
274            '0' => { continue; }
275            _ => {}
276        }
277        if lc.is_ascii_alphabetic() || (lc.is_ascii_digit() && !ls.is_empty()) { ls.push(Letter::new(ch, upper)); }
278    }
279    emit(&ls)
280}
281
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    // modern=true, short_w=false (current default behavior)
287    fn t(s: &str) -> String { convert_telex(s, true, false) }
288    fn tw(s: &str) -> String { convert_telex(s, true, true) }
289    // traditional tone placement (short_w enabled)
290    fn tt(s: &str) -> String { convert_telex(s, false, true) }
291    fn vt(s: &str) -> String { convert_vni(s, false, true) }
292
293    #[test] fn telex_basic() {
294        assert_eq!(t("as"),"á"); assert_eq!(t("af"),"à"); assert_eq!(t("ar"),"ả"); assert_eq!(t("ax"),"ã"); assert_eq!(t("aj"),"ạ");
295        assert_eq!(t("aw"),"ă"); assert_eq!(t("aa"),"â"); assert_eq!(t("ee"),"ê"); assert_eq!(t("oo"),"ô"); assert_eq!(t("ow"),"ơ");
296        assert_eq!(t("uw"),"ư"); assert_eq!(t("dd"),"đ");
297    }
298    #[test] fn telex_compound() {
299        assert_eq!(t("aws"),"ắ"); assert_eq!(t("aas"),"ấ"); assert_eq!(t("ees"),"ế"); assert_eq!(t("oos"),"ố"); assert_eq!(t("ows"),"ớ"); assert_eq!(t("uws"),"ứ");
300    }
301    #[test] fn telex_words() {
302        assert_eq!(t("xin"),"xin"); assert_eq!(t("tooi"),"tôi"); assert_eq!(t("laf"),"là"); assert_eq!(t("cos"),"có");
303        assert_eq!(t("khoong"),"không"); assert_eq!(t("tieengs"),"tiếng"); assert_eq!(t("Vieetj"),"Việt");
304    }
305    #[test] fn telex_uow() {
306        assert_eq!(t("uow"),"ươ"); assert_eq!(t("cuowcs"),"cước"); assert_eq!(t("dduwowcj"),"được"); assert_eq!(t("nguwowif"),"người");
307    }
308    #[test] fn telex_gi_qu() {
309        assert_eq!(t("gif"),"gì"); assert_eq!(t("giowf"),"giờ"); assert_eq!(t("gioir"),"giỏi"); assert_eq!(t("gias"),"giá"); assert_eq!(t("quas"),"quá");
310    }
311    #[test] fn telex_tone_placement() {
312        assert_eq!(t("chafo"),"chào"); assert_eq!(t("toans"),"toán"); assert_eq!(t("hoaf"),"hoà"); assert_eq!(t("thowif"),"thời");
313        assert_eq!(t("cuar"),"của"); assert_eq!(t("thoix"),"thõi");
314        // òa/oà both accessible via typing order:
315        assert_eq!(t("ofa"),"òa");  // tone between vowels → on first vowel
316        assert_eq!(t("oaf"),"oà");  // tone after all vowels → on second vowel
317    }
318    #[test] fn telex_english() {
319        assert_eq!(t("hello"),"hello");
320        assert_eq!(t("wood"),"wôd");
321        assert_eq!(t("reboot"),"rebôt");
322    }
323    #[test] fn telex_z() { assert_eq!(t("asz"),"a"); }
324    #[test] fn telex_w_standalone() {
325        assert_eq!(t("w"),"w");     // without short_w: w stays as w
326        assert_eq!(tw("w"),"ư");    // with short_w: standalone w → ư
327        assert_eq!(tw("ws"),"ứ");
328        assert_eq!(tw("wf"),"ừ");
329        assert_eq!(tw("wn"),"ưn");
330    }
331
332    // ── traditional tone placement (modern=false, short_w=true) ─────────────
333
334    #[test] fn telex_dd_traditional() {
335        assert_eq!(tt("dd"),"đ"); assert_eq!(tt("DD"),"Đ");
336        assert_eq!(tt("Dd"),"Đ"); assert_eq!(tt("dD"),"Đ");
337    }
338    #[test] fn telex_marks_traditional() {
339        assert_eq!(tt("aa"),"â"); assert_eq!(tt("aw"),"ă");
340        assert_eq!(tt("ee"),"ê"); assert_eq!(tt("ew"),"ê");
341        assert_eq!(tt("oo"),"ô"); assert_eq!(tt("ow"),"ơ");
342        assert_eq!(tt("uw"),"ư"); assert_eq!(tt("uow"),"ươ");
343    }
344    #[test] fn telex_w_traditional() {
345        assert_eq!(tt("w"),"ư"); assert_eq!(tt("W"),"Ư");
346        assert_eq!(tt("wn"),"ưn"); assert_eq!(tt("ws"),"ứ"); assert_eq!(tt("wf"),"ừ");
347    }
348    #[test] fn telex_compound_traditional() {
349        assert_eq!(tt("aws"),"ắ"); assert_eq!(tt("aas"),"ấ"); assert_eq!(tt("ees"),"ế");
350        assert_eq!(tt("oos"),"ố"); assert_eq!(tt("ows"),"ớ"); assert_eq!(tt("uws"),"ứ");
351    }
352    #[test] fn telex_words_traditional() {
353        assert_eq!(tt("chaof"),"chào"); assert_eq!(tt("vieejt"),"việt");
354        assert_eq!(tt("tieengs"),"tiếng"); assert_eq!(tt("quas"),"quá");
355        assert_eq!(tt("gias"),"giá"); assert_eq!(tt("xin"),"xin");
356        assert_eq!(tt("tooi"),"tôi"); assert_eq!(tt("laf"),"là"); assert_eq!(tt("cos"),"có");
357        assert_eq!(tt("khoong"),"không"); assert_eq!(tt("Vieetj"),"Việt");
358    }
359    #[test] fn telex_tone_traditional() {
360        // traditional: tone on 2nd vowel for oa/oe/uy open diphthongs
361        assert_eq!(tt("toans"),"toán"); assert_eq!(tt("hoaf"),"hoà");
362        assert_eq!(tt("thowif"),"thời"); assert_eq!(tt("cuar"),"của");
363    }
364    #[test] fn telex_gi_qu_traditional() {
365        assert_eq!(tt("gif"),"gì"); assert_eq!(tt("giowf"),"giờ");
366        assert_eq!(tt("gioir"),"giỏi");
367    }
368    #[test] fn telex_uow_traditional() {
369        assert_eq!(tt("cuowcs"),"cước"); assert_eq!(tt("dduwowcj"),"được");
370        assert_eq!(tt("nguwowif"),"người");
371    }
372    #[test] fn telex_z_traditional() { assert_eq!(tt("asz"),"a"); }
373    #[test] fn telex_english_traditional() { assert_eq!(tt("hello"),"hello"); }
374    #[test] fn telex_more_traditional() {
375        assert_eq!(tt("hoanf"),"hoàn"); assert_eq!(tt("huyeenf"),"huyền");
376        assert_eq!(tt("nguyeexn"),"nguyễn"); assert_eq!(tt("ngoaif"),"ngoài");
377        assert_eq!(tt("thuowngf"),"thường");
378    }
379    #[test] fn vni_marks_traditional() {
380        assert_eq!(vt("d9"),"đ"); assert_eq!(vt("a6"),"â"); assert_eq!(vt("a8"),"ă");
381        assert_eq!(vt("e6"),"ê"); assert_eq!(vt("o6"),"ô"); assert_eq!(vt("o7"),"ơ");
382        assert_eq!(vt("u7"),"ư"); assert_eq!(vt("uo7"),"ươ");
383    }
384    #[test] fn vni_words_traditional() {
385        assert_eq!(vt("vie65t"),"việt"); assert_eq!(vt("tie61ng"),"tiếng");
386        assert_eq!(vt("qua1"),"quá"); assert_eq!(vt("gia1"),"giá");
387    }
388
389    // ── Tone replacement tests ─────────────────────────────────────
390    #[test] fn telex_tone_replace() {
391        assert_eq!(tt("saosf"),"sào");  // sáo + f → sào (acute→grave)
392        assert_eq!(tt("saojs"),"sáo"); // sạ + s → sáo (dot→acute)
393        assert_eq!(tt("saojr"),"sảo"); // sạo + r → sảo (dot→hook)
394        assert_eq!(tt("hoasf"),"hoà"); // hóa + f → hoà (acute→grave)
395        assert_eq!(tt("toansr"),"toản");// toán + r → toản (acute→hook)
396    }
397    #[test] fn teipvni_tone_replace() {
398        assert_eq!(tv("saosf"),"sào");  // Telex acute→grave in combined mode
399        assert_eq!(tv("sao1f"),"sào");  // VNI acute + Telex grave
400    }
401
402    // ── Unikey-style triple-press toggle ────────────────────────────
403    #[test] fn telex_triple_toggle() {
404        assert_eq!(tt("rooot"),"root");  // oo→ô, ooo→oo
405        assert_eq!(tt("roo"),"rô");
406        assert_eq!(tt("aa"),"â");        // double → circumflex
407        assert_eq!(tt("aaa"),"aa");      // triple → undo
408        assert_eq!(tt("aaaa"),"aâ");     // quadruple → circumflex again
409        assert_eq!(tt("ee"),"ê");
410        assert_eq!(tt("eee"),"ee");
411        assert_eq!(tt("oooo"),"oô");     // oo→ô, ooo→oo, oooo→oô
412    }
413
414    // ── Unikey-style double-vowel across y ──────────────────────────
415    #[test] fn telex_double_vowel_across_y() {
416        assert_eq!(tt("vaya"),"vây");   // vay + a → vây
417        assert_eq!(tt("vayaj"),"vậy");  // vay + a + j → vậy
418        assert_eq!(tt("vayas"),"vấy");  // vay + a + s → vấy
419        assert_eq!(tt("vayaf"),"vầy");  // vay + a + f → vầy
420        assert_eq!(tt("mayas"),"mấy");  // may + a + s → mấy
421        assert_eq!(tt("dayaj"),"dậy");  // day + a + j → dậy
422        assert_eq!(tt("quaya"),"quây"); // quay + a → quây
423        assert_eq!(tt("quayar"),"quẩy");// quay + a + r → quẩy
424        // au + a → âu
425        assert_eq!(tt("saua"),"sâu");
426        assert_eq!(tt("sauas"),"sấu");
427        assert_eq!(tt("dauaf"),"dầu");
428        // ee still works (immediate neighbor)
429        assert_eq!(tt("ee"),"ê");
430        // aay still works
431        assert_eq!(tt("aay"),"ây");
432    }
433
434    // ── w-target tests: w applies to last modifiable vowel ──────────
435    #[test] fn telex_w_target() {
436        // toi+w: w modifies 'o' (last modifiable), not 'i'
437        assert_eq!(tt("toiw"),"tơi");
438        assert_eq!(tt("toiws"),"tới");  // toi + w + sắc → tới
439        assert_eq!(tt("toiwf"),"tời");  // toi + w + huyền → tời
440        assert_eq!(tt("toiwr"),"tởi");  // toi + w + hỏi → tởi
441        assert_eq!(tt("toiwj"),"tợi");  // toi + w + nặng → tợi
442        // nguoi + w + f = người (w→ơ on o, uow→ươ, f→huyền on ư)
443        assert_eq!(tt("nguoiwf"),"người");
444        assert_eq!(tt("nguoiws"),"ngưới");
445        // muoi + w + f = mười
446        assert_eq!(tt("muoiwf"),"mười");
447        // tuoi + w + s = tưới
448        assert_eq!(tt("tuoiws"),"tưới");
449        // "tuổi" correctly typed as tuooir (oo→ô)
450        assert_eq!(tt("tuooir"),"tuổi");
451        assert_eq!(tt("tuooif"),"tuồi");
452    }
453    #[test] fn telex_toan() {
454        // "toàn" has trailing consonant → tone on 2nd vowel always
455        assert_eq!(tt("toanf"),"toàn");
456        assert_eq!(t("toanf"),"toàn");
457        assert_eq!(tt("hoanf"),"hoàn");
458        assert_eq!(t("hoanf"),"hoàn");
459        // open diphthong oa/oe/uy: always 2nd vowel
460        // òa typed as ofa (tone between vowels), oà typed as oaf (tone after)
461        assert_eq!(t("oaf"),"oà");   // tone after all vowels → 2nd
462        assert_eq!(t("ofa"),"òa");   // tone between vowels → 1st
463        assert_eq!(tt("oaf"),"oà");
464        assert_eq!(tt("ofa"),"òa");  // same
465    }
466
467    // ── TeipVni (combined Telex + VNI) tests ─────────────────────────
468    fn tv(s: &str) -> String { convert_teip_vni(s, false, true) }
469
470    #[test] fn teipvni_telex_words() {
471        assert_eq!(tv("vieejt"),"việt"); assert_eq!(tv("dd"),"đ");
472        assert_eq!(tv("aa"),"â"); assert_eq!(tv("aw"),"ă");
473        assert_eq!(tv("ee"),"ê"); assert_eq!(tv("oo"),"ô");
474        assert_eq!(tv("ow"),"ơ"); assert_eq!(tv("uw"),"ư");
475        assert_eq!(tv("uow"),"ươ"); assert_eq!(tv("w"),"ư");
476    }
477    #[test] fn teipvni_vni_marks() {
478        assert_eq!(tv("a6"),"â"); assert_eq!(tv("a8"),"ă");
479        assert_eq!(tv("e6"),"ê"); assert_eq!(tv("o6"),"ô");
480        assert_eq!(tv("o7"),"ơ"); assert_eq!(tv("u7"),"ư");
481        assert_eq!(tv("uo7"),"ươ"); assert_eq!(tv("d9"),"đ");
482    }
483    #[test] fn teipvni_numbers() {
484        // VNI tones
485        assert_eq!(tv("qua1"),"quá"); assert_eq!(tv("gia1"),"giá");
486        assert_eq!(tv("vie65t"),"việt"); assert_eq!(tv("tie61ng"),"tiếng");
487    }
488    #[test] fn teipvni_mixed() {
489        // Mix Telex and VNI in same stream
490        assert_eq!(tv("vie6t"),"viêt"); // VNI circumflex + Telex
491        assert_eq!(tv("vie6ts"),"viết"); // VNI circumflex + Telex tone
492        assert_eq!(tv("tie6ngs"),"tiếng");
493    }
494
495    // ── VIQR tests ──────────────────────────────────────────────────
496    fn viqr(s: &str) -> String { convert_viqr(s) }
497
498    #[test] fn viqr_marks() {
499        assert_eq!(viqr("a^"),"â"); assert_eq!(viqr("a("),"ă");
500        assert_eq!(viqr("e^"),"ê"); assert_eq!(viqr("o^"),"ô");
501        assert_eq!(viqr("o+"),"ơ"); assert_eq!(viqr("u+"),"ư");
502        assert_eq!(viqr("dd"),"đ");
503    }
504    #[test] fn viqr_tones() {
505        assert_eq!(viqr("a'"),"á"); assert_eq!(viqr("a`"),"à");
506        assert_eq!(viqr("a?"),"ả"); assert_eq!(viqr("a~"),"ã");
507        assert_eq!(viqr("a."),"ạ");
508    }
509    #[test] fn viqr_words() {
510        assert_eq!(viqr("vie^'t"),"viết");
511        assert_eq!(viqr("nu+o+'c"),"nước");
512        assert_eq!(viqr("ngu+o+`i"),"người");
513    }
514    #[test] fn viqr_dd() {
515        assert_eq!(viqr("dd"),"đ"); assert_eq!(viqr("DD"),"Đ");
516    }
517    #[test] fn viqr_english() { assert_eq!(viqr("hello"),"hello"); }
518}
519
520#[test]
521fn full_parity() {
522    // This test verifies full parity for all 73 reference cases.
523    // traditional settings: modern=false (traditional tone placement), short_w=true
524
525    struct Case { input: &'static str, expected: &'static str, method: u8 }
526    // method: 1=Telex, 2=VNI, 3=VIQR, 4=TeipVni
527    let cases = [
528        // ── Telex ──
529        Case{input:"dd",expected:"đ",method:1},Case{input:"DD",expected:"Đ",method:1},
530        Case{input:"Dd",expected:"Đ",method:1},Case{input:"dD",expected:"Đ",method:1},
531        Case{input:"aa",expected:"â",method:1},Case{input:"aw",expected:"ă",method:1},
532        Case{input:"ee",expected:"ê",method:1},Case{input:"ew",expected:"ê",method:1},
533        Case{input:"oo",expected:"ô",method:1},Case{input:"ow",expected:"ơ",method:1},
534        Case{input:"uw",expected:"ư",method:1},Case{input:"uow",expected:"ươ",method:1},
535        Case{input:"w",expected:"ư",method:1},Case{input:"W",expected:"Ư",method:1},
536        Case{input:"wn",expected:"ưn",method:1},Case{input:"ws",expected:"ứ",method:1},
537        Case{input:"wf",expected:"ừ",method:1},
538        Case{input:"aws",expected:"ắ",method:1},Case{input:"aas",expected:"ấ",method:1},
539        Case{input:"ees",expected:"ế",method:1},Case{input:"oos",expected:"ố",method:1},
540        Case{input:"ows",expected:"ớ",method:1},Case{input:"uws",expected:"ứ",method:1},
541        Case{input:"chaof",expected:"chào",method:1},Case{input:"vieejt",expected:"việt",method:1},
542        Case{input:"tieengs",expected:"tiếng",method:1},Case{input:"quas",expected:"quá",method:1},
543        Case{input:"gias",expected:"giá",method:1},Case{input:"xin",expected:"xin",method:1},
544        Case{input:"tooi",expected:"tôi",method:1},Case{input:"laf",expected:"là",method:1},
545        Case{input:"cos",expected:"có",method:1},Case{input:"khoong",expected:"không",method:1},
546        Case{input:"Vieetj",expected:"Việt",method:1},
547        Case{input:"toans",expected:"toán",method:1},Case{input:"hoaf",expected:"hoà",method:1},
548        Case{input:"thowif",expected:"thời",method:1},Case{input:"cuar",expected:"của",method:1},
549        Case{input:"gif",expected:"gì",method:1},Case{input:"giowf",expected:"giờ",method:1},
550        Case{input:"gioir",expected:"giỏi",method:1},
551        Case{input:"cuowcs",expected:"cước",method:1},Case{input:"dduwowcj",expected:"được",method:1},
552        Case{input:"nguwowif",expected:"người",method:1},
553        Case{input:"asz",expected:"a",method:1},Case{input:"hello",expected:"hello",method:1},
554        Case{input:"hoanf",expected:"hoàn",method:1},Case{input:"huyeenf",expected:"huyền",method:1},
555        Case{input:"nguyeexn",expected:"nguyễn",method:1},Case{input:"ngoaif",expected:"ngoài",method:1},
556        Case{input:"thuowngf",expected:"thường",method:1},
557        // ── VNI ──
558        Case{input:"d9",expected:"đ",method:2},Case{input:"a6",expected:"â",method:2},
559        Case{input:"a8",expected:"ă",method:2},Case{input:"e6",expected:"ê",method:2},
560        Case{input:"o6",expected:"ô",method:2},Case{input:"o7",expected:"ơ",method:2},
561        Case{input:"u7",expected:"ư",method:2},Case{input:"uo7",expected:"ươ",method:2},
562        Case{input:"vie65t",expected:"việt",method:2},Case{input:"tie61ng",expected:"tiếng",method:2},
563        Case{input:"qua1",expected:"quá",method:2},Case{input:"gia1",expected:"giá",method:2},
564        // ── TeipVni ──
565        Case{input:"vie61t",expected:"viết",method:4},Case{input:"vieejt",expected:"việt",method:4},
566        Case{input:"tie61ng",expected:"tiếng",method:4},
567        Case{input:"a6",expected:"â",method:4},Case{input:"dd",expected:"đ",method:4},
568        // ── VIQR ──
569        Case{input:"dd",expected:"đ",method:3},Case{input:"DD",expected:"Đ",method:3},
570        Case{input:"vie^'t",expected:"viết",method:3},Case{input:"nu+o+'c",expected:"nước",method:3},
571        Case{input:"ngu+o+`i",expected:"người",method:3},
572    ];
573
574    let mut failures = Vec::new();
575    for c in &cases {
576        let result = match c.method {
577            1 => convert_telex(c.input, false, true),
578            2 => convert_vni(c.input, false, true),
579            3 => convert_viqr(c.input),
580            4 => convert_teip_vni(c.input, false, true),
581            _ => unreachable!(),
582        };
583        if result != c.expected {
584            failures.push(format!("[m{}] '{}' -> '{}' (expected '{}')", c.method, c.input, result, c.expected));
585        }
586    }
587    assert!(failures.is_empty(), "{} full parity failures:\n{}", failures.len(), failures.join("\n"));
588}
589
590#[test]
591fn comprehensive_vietnamese() {
592    // Comprehensive test covering all Vietnamese syllable patterns.
593    // Uses traditional settings: modern=false, short_w=true.
594    // method: 1=Telex, 2=VNI, 3=VIQR, 4=TeipVni.
595    // Method 0 = Telex (traditional) for brevity.
596
597    let cases: &[(&str, &str, u8)] = &[
598        // ═══ SINGLE VOWELS — 5 tones each ═══
599        ("as","á",1),("af","à",1),("ar","ả",1),("ax","ã",1),("aj","ạ",1),
600        ("es","é",1),("ef","è",1),("er","ẻ",1),("ex","ẽ",1),("ej","ẹ",1),
601        ("is","í",1),("if","ì",1),("ir","ỉ",1),("ix","ĩ",1),("ij","ị",1),
602        ("os","ó",1),("of","ò",1),("or","ỏ",1),("ox","õ",1),("oj","ọ",1),
603        ("us","ú",1),("uf","ù",1),("ur","ủ",1),("ux","ũ",1),("uj","ụ",1),
604        ("ys","ý",1),("yf","ỳ",1),("yr","ỷ",1),("yx","ỹ",1),("yj","ỵ",1),
605
606        // ═══ MARKED VOWELS (breve/circumflex/horn) + tones ═══
607        // ă = aw
608        ("aw","ă",1),("aws","ắ",1),("awf","ằ",1),("awr","ẳ",1),("awx","ẵ",1),("awj","ặ",1),
609        // â = aa
610        ("aa","â",1),("aas","ấ",1),("aaf","ầ",1),("aar","ẩ",1),("aax","ẫ",1),("aaj","ậ",1),
611        // ê = ee / ew
612        ("ee","ê",1),("ees","ế",1),("eef","ề",1),("eer","ể",1),("eex","ễ",1),("eej","ệ",1),
613        ("ew","ê",1),("ews","ế",1),("ewf","ề",1),("ewr","ể",1),
614        // ô = oo
615        ("oo","ô",1),("oos","ố",1),("oof","ồ",1),("oor","ổ",1),("oox","ỗ",1),("ooj","ộ",1),
616        // ơ = ow
617        ("ow","ơ",1),("ows","ớ",1),("owf","ờ",1),("owr","ở",1),("owx","ỡ",1),("owj","ợ",1),
618        // ư = uw
619        ("uw","ư",1),("uws","ứ",1),("uwf","ừ",1),("uwr","ử",1),("uwx","ữ",1),("uwj","ự",1),
620
621        // ═══ dd → đ ═══
622        ("dd","đ",1),
623
624        // ═══ STANDALONE w → ư ═══
625        ("w","ư",1),("ws","ứ",1),("wf","ừ",1),("wr","ử",1),("wx","ữ",1),("wj","ự",1),
626        ("wn","ưn",1),("wt","ưt",1),
627
628        // ═══ DIPHTHONGS — closed (trailing consonant) ═══
629        // ai, ao, au
630        ("ais","ái",1),("aif","ài",1),("air","ải",1),("aix","ãi",1),("aij","ại",1),
631        ("aos","áo",1),("aof","ào",1),("aor","ảo",1),("aox","ão",1),("aoj","ạo",1),
632        ("aus","áu",1),("auf","àu",1),("aur","ảu",1),("aux","ãu",1),("auj","ạu",1),
633        // ây, ay
634        ("aays","ấy",1),("aayf","ầy",1),("aayr","ẩy",1),("aayx","ẫy",1),("aayj","ậy",1),
635        ("ays","áy",1),("ayf","ày",1),("ayr","ảy",1),("ayx","ãy",1),("ayj","ạy",1),
636        // eo, êu
637        ("eos","éo",1),("eof","èo",1),("eor","ẻo",1),("eox","ẽo",1),("eoj","ẹo",1),
638        ("eeus","ếu",1),("eeuf","ều",1),("eeur","ểu",1),("eeux","ễu",1),("eeuj","ệu",1),
639        // oi, ôi, ơi, ui, ưi, ưu
640        ("ois","ói",1),("oif","òi",1),("oir","ỏi",1),("oix","õi",1),("oij","ọi",1),
641        ("oois","ối",1),("ooif","ồi",1),("ooir","ổi",1),("ooix","ỗi",1),("ooij","ội",1),
642        ("owis","ới",1),("owif","ời",1),("owir","ởi",1),("owix","ỡi",1),("owij","ợi",1),
643        ("uis","úi",1),("uif","ùi",1),("uir","ủi",1),("uix","ũi",1),("uij","ụi",1),
644        ("uwis","ứi",1),("uwif","ừi",1),("uwir","ửi",1),("uwix","ữi",1),("uwij","ựi",1),
645        ("uwus","ứu",1),("uwuf","ừu",1),("uwur","ửu",1),("uwux","ữu",1),("uwuj","ựu",1),
646
647        // ═══ oa, oe, uy — closed (trailing consonant, tone on 2nd vowel) ═══
648        ("oans","oán",1),("oanf","oàn",1),("oanr","oản",1),("oanx","oãn",1),("oanj","oạn",1),
649        ("oats","oát",1),("oatf","oàt",1),("oatr","oảt",1),
650        ("oens","oén",1),("oenf","oèn",1),
651        ("uyns","uýn",1),("uynf","uỳn",1),("uynr","uỷn",1),
652
653        // ═══ oa, oe, uy — open (traditional: tone on 2nd vowel) ═══
654        ("oas","oá",1),("oaf","oà",1),("oar","oả",1),("oax","oã",1),("oaj","oạ",1),
655        ("oes","oé",1),("oef","oè",1),
656        ("uys","uý",1),("uyf","uỳ",1),("uyr","uỷ",1),("uyx","uỹ",1),("uyj","uỵ",1),
657
658        // ═══ TRIPHTHONGS ═══
659        // oai, oay (tone on middle vowel)
660        ("oais","oái",1),("oaif","oài",1),("oair","oải",1),("oaix","oãi",1),("oaij","oại",1),
661        ("oays","oáy",1),("oayf","oày",1),("oayr","oảy",1),("oayx","oãy",1),("oayj","oạy",1),
662        // uây (tone on middle vowel â)
663        ("uaays","uấy",1),("uaayf","uầy",1),("uaayr","uẩy",1),("uaayx","uẫy",1),("uaayj","uậy",1),
664        // uyê (tone on ê — 3rd vowel, the one with variant)
665        ("uyeef","uyề",1),("uyees","uyế",1),("uyeer","uyể",1),("uyeex","uyễ",1),("uyeej","uyệ",1),
666        // iê, yê
667        ("ieef","iề",1),("iees","iế",1),("ieer","iể",1),("ieex","iễ",1),("ieej","iệ",1),
668        ("yeef","yề",1),("yees","yế",1),("yeer","yể",1),("yeex","yễ",1),("yeej","yệ",1),
669        // uôi = u + ô + i (oo→ô, tone on ô which has variant≠0)
670        ("uoois","uối",1),("uooif","uồi",1),("uooir","uổi",1),("uooix","uỗi",1),("uooij","uội",1),
671        // ươi = u + ơ + i (w targets o, uow spread → ươ, tone on ơ which has variant≠0)
672        ("uoiws","ưới",1),("uoiwf","ười",1),("uoiwr","ưởi",1),("uoiwx","ưỡi",1),("uoiwj","ượi",1),
673
674        // ═══ GI / QU initial consonant digraphs ═══
675        ("gif","gì",1),("gis","gí",1),("gir","gỉ",1),("gix","gĩ",1),("gij","gị",1),
676        ("gias","giá",1),("giaf","già",1),("giar","giả",1),("giax","giã",1),("giaj","giạ",1),
677        ("gioir","giỏi",1),("gioif","giòi",1),("giois","giói",1),
678        ("quas","quá",1),("quaf","quà",1),("quar","quả",1),("quax","quã",1),("quaj","quạ",1),
679        ("quys","quý",1),("quyf","quỳ",1),("quyr","quỷ",1),("quyx","quỹ",1),("quyj","quỵ",1),
680
681        // ═══ REAL WORDS ═══
682        ("chaof","chào",1),("vieejt","việt",1),("tieengs","tiếng",1),
683        ("khoong","không",1),("cuar","của",1),("cuowcs","cước",1),
684        ("dduwowcj","được",1),("nguwowif","người",1),("thuowngf","thường",1),
685        ("hoanf","hoàn",1),("huyeenf","huyền",1),("nguyeexn","nguyễn",1),
686        ("ngoaif","ngoài",1),("ngoafi","ngoài",1),
687        ("vaya","vây",1),("vayaj","vậy",1),("vayas","vấy",1),("mayas","mấy",1),
688        // Double-vowel across consonant: nhan+a→nhân, khan+a→khân
689        ("nhana","nhân",1),("khanas","khấn",1),("nhanaa","nhana",1),
690        ("toans","toán",1),("thowif","thời",1),
691        ("xin","xin",1),("tooi","tôi",1),("laf","là",1),("cos","có",1),
692        ("banj","bạn",1),("minhf","mình",1),("nawm","năm",1),("thangs","tháng",1),
693        ("truwowngf","trường",1),("luaatj","luật",1),
694
695        // ═══ CONSONANT CLUSTERS ═══
696        ("nhas","nhá",1),("nghir","nghỉ",1),("gheps","ghép",1),
697        ("truws","trứ",1),("thas","thá",1),("phos","phó",1),
698        ("khas","khá",1),("chays","cháy",1),("ngays","ngáy",1),
699
700        // ═══ FINAL CONSONANT VARIETY ═══
701        ("ddejp","đẹp",1),("maats","mất",1),("bacs","bác",1),
702        ("ngachs","ngách",1),("banhf","bành",1),
703        ("tins","tín",1),("metj","mẹt",1),
704
705        // ═══ z (tone removal) ═══
706        ("asz","a",1),("awsjz","ă",1),("aasz","â",1),
707
708        // ═══ ENGLISH PASS-THROUGH (words without Telex tone/mark keys s/f/r/x/j/w/z) ═══
709        ("hello","hello",1),("bacon","bacon",1),("thing","thing",1),("climb","climb",1),
710
711        // ═══ CAPITALIZATION ═══
712        ("As","Á",1),("Af","À",1),("Aas","Ấ",1),("Aws","Ắ",1),
713        ("Dd","Đ",1),("DD","Đ",1),
714        ("W","Ư",1),("Ws","Ứ",1),
715        ("Vieetj","Việt",1),("Tooi","Tôi",1),
716
717        // ═══ VNI method ═══
718        ("d9","đ",2),("a6","â",2),("a8","ă",2),("e6","ê",2),
719        ("o6","ô",2),("o7","ơ",2),("u7","ư",2),("uo7","ươ",2),
720        ("vie65t","việt",2),("tie61ng","tiếng",2),
721        ("qua1","quá",2),("gia1","giá",2),
722
723        // ═══ TeipVni combined ═══
724        ("vie61t","viết",4),("vieejt","việt",4),
725        ("tie61ng","tiếng",4),("a6","â",4),("dd","đ",4),
726
727        // ═══ VIQR method ═══
728        ("dd","đ",3),("DD","Đ",3),
729        ("vie^'t","viết",3),("nu+o+'c","nước",3),("ngu+o+`i","người",3),
730    ];
731
732    let mut failures = Vec::new();
733    for &(input, expected, method) in cases {
734        let result = match method {
735            1 => convert_telex(input, false, true),
736            2 => convert_vni(input, false, true),
737            3 => convert_viqr(input),
738            4 => convert_teip_vni(input, false, true),
739            _ => unreachable!(),
740        };
741        if result != expected {
742            failures.push(format!("[m{}] '{}' -> '{}' (expected '{}')", method, input, result, expected));
743        }
744    }
745    assert!(failures.is_empty(),
746        "{} comprehensive failures (traditional mode):\n{}",
747        failures.len(), failures.join("\n"));
748}