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