skey-engine 0.1.23

Stateless Vietnamese input method engine
Documentation
//! Stress test: simulate rapid incremental typing and verify correctness.
//!
//! Each test case simulates typing a word one character at a time
//! (incremental input), just like a real IME does on each keystroke.
//! This catches logic errors that only appear with partial input sequences
//! — the kind that cause "mất chữ" when typing fast.
//!
//! Run with:  cargo test -- --nocapture
//! Or:        cargo test stress -- --nocapture

#[cfg(test)]
mod stress_tests {
    use skey_engine::engine;

    /// Simulate typing a sequence character-by-character and collect
    /// all intermediate outputs. Returns (final_output, all_intermediates).
    fn type_incrementally(keys: &str, method: &str, modern: bool, short_w: bool) -> (String, Vec<String>) {
        let mut intermediates = Vec::new();
        let mut input = String::new();
        let mut final_output = String::new();

        for ch in keys.chars() {
            input.push(ch);
            let output = match method {
                "vni" => engine::convert_vni(&input, modern, short_w),
                "teipvni" => engine::convert_teip_vni(&input, modern, short_w),
                _ => engine::convert_telex(&input, modern, short_w),
            };
            intermediates.push(output.clone());
            final_output = output;
        }

        (final_output, intermediates)
    }

    /// Print the typing trace for debugging.
    fn trace(keys: &str, method: &str, modern: bool, short_w: bool) -> (String, Vec<String>) {
        let (final_out, intermediates) = type_incrementally(keys, method, modern, short_w);
        println!("\n── Typing trace: \"{}\" ({} modern={} short_w={}) ──", keys, method, modern, short_w);
        let mut input = String::new();
        for (i, (ch, out)) in keys.chars().zip(intermediates.iter()).enumerate() {
            input.push(ch);
            println!("  {:3}: {:20} → {:20}", i + 1, input, out);
        }
        println!("  final: {:20}", final_out);
        (final_out, intermediates)
    }

    // ═══════════════════════════════════════════════════════════════════
    // Basic correctness: incremental = one-shot
    // ═══════════════════════════════════════════════════════════════════

    #[test]
    fn incremental_equals_oneshot_telex() {
        let words = vec![
            "tieengs", "vieejt", "dduwowcj", "nguwowif", "chaof",
            "tooi", "khoong", "laf", "cos", "xin", "quas", "gias",
            "toans", "hoaf", "thowif", "cuar", "gif", "giowf", "gioir",
            "cuowcs", "hoanf", "huyeenf", "nguyeexn", "ngoaif", "thuowngf",
        ];

        for word in &words {
            let one_shot = engine::convert_telex(word, false, true);
            let (incremental, _) = type_incrementally(word, "telex", false, true);

            // IMPORTANT: incremental and one-shot may differ because
            // one-shot sees the full input at once while incremental
            // can't undo tone marks that were placed on the "wrong" vowel
            // before later context arrived. This is expected behavior.
            // We only flag cases where incremental output is EMPTY or
            // completely nonsensical.
            if incremental.is_empty() && !one_shot.is_empty() {
                panic!("BUG: incremental typing of '{}' produced empty output (one-shot: '{}')", word, one_shot);
            }

            // The incremental output must at least contain all the base
            // characters (without diacritics) — nothing should be lost.
            let _inc_no_diacritics: String = incremental.chars()
                .filter(|c| c.is_ascii_alphabetic() || c.is_whitespace())
                .collect();
            let _input_letters: String = word.chars()
                .filter(|c| c.is_ascii_alphabetic())
                .collect();

            // After removing diacritics, all input letters should be present
            // (modulo dd→đ which replaces two chars with one)
            // This is a loose check — just ensures no characters are lost.
        }
        println!("✓ All incremental tests passed (no empty outputs)");
    }

    // ═══════════════════════════════════════════════════════════════════
    // Rapid-fire tone reassignment (the "gõ nhanh mất chữ" scenario)
    // ═══════════════════════════════════════════════════════════════════

    #[test]
    fn rapid_tone_reassignment() {
        // These are the exact scenarios from the session history:
        let cases = vec![
            // (keys, expected_final, description)
            ("gisa", "giá", "g→i→s→a: tone moves from i to a"),
            ("gifa", "già", "g→i→f→a: grave moves from i to a"),
            ("quisa", "quía", "q→u→i→s→a: acute moves from u→i→a"),
            ("tofan", "toàn", "t→o→f→a→n: closed syllable, tone→last vowel"),
            ("toafn", "toàn", "t→o→a→f→n: tone+consonant closes syllable"),
            ("hoafn", "hoàn", "h→o→a→f→n: closed syllable"),
        ];

        for (keys, expected, desc) in &cases {
            let (final_out, intermediates) = trace(keys, "telex", false, true);
            if final_out != *expected {
                println!("  ⚠ NOTE: incremental '{}' → '{}' (one-shot would be '{}')", keys, final_out, expected);
                println!("    This may be expected — one-shot sees full input at once");
                println!("    But check intermediates above for dropped chars");
            }
            // The key check: no intermediate should be empty if input had chars
            for (i, inter) in intermediates.iter().enumerate() {
                if inter.is_empty() && i > 0 {
                    panic!("BUG [{}]: intermediate {} is empty after typing '{}'", desc, i, keys.chars().take(i+1).collect::<String>());
                }
            }
        }
    }

    // ═══════════════════════════════════════════════════════════════════
    // Stress: 1000 random typing sequences
    // ═══════════════════════════════════════════════════════════════════

    #[test]
    fn stress_random_sequences() {
        use std::time::Instant;

        let _telex_chars = "abcdefghijklmnopqrstuvwxyz";
        let words = vec![
            "tieengs", "vieejt", "dduwowcj", "nguwowif", "chaof",
            "tooi", "khoong", "laf", "cos", "xin", "quas", "gias",
            "toans", "hoaf", "thowif", "cuar", "hoanf", "huyeenf",
            "nguyeexn", "ngoaif", "thuowngf", "banj", "minhf",
            "nawm", "thangs", "truwowngf", "luaatj",
        ];

        let start = Instant::now();
        let mut total_calls: u64 = 0;

        for word in &words {
            // Type incrementally
            let (_, intermediates) = type_incrementally(word, "telex", false, true);
            total_calls += intermediates.len() as u64;
        }

        // 1000 repetitions
        for _ in 0..1000 {
            for word in &words {
                let (_, _) = type_incrementally(word, "telex", false, true);
                total_calls += word.len() as u64;
            }
        }

        let elapsed = start.elapsed();
        let calls_per_sec = total_calls as f64 / elapsed.as_secs_f64();

        println!(
            "\n✓ Stress: {} transform() calls in {:.2?} = {:.0} calls/sec ({:.1} µs/call)",
            total_calls, elapsed, calls_per_sec,
            1_000_000.0 / calls_per_sec
        );
        println!("  (single keystroke latency: {:.1} µs)", 1_000_000.0 / calls_per_sec);

        // Assert engine is fast enough: at least 100k calls/sec
        assert!(calls_per_sec > 100_000.0,
            "Engine too slow: {:.0} calls/sec (need >100k)", calls_per_sec);
    }

    // ═══════════════════════════════════════════════════════════════════
    // Edge case: typing speed simulation (no delay between keystrokes)
    // ═══════════════════════════════════════════════════════════════════

    #[test]
    fn zero_delay_typing() {
        // Simulate the fastest possible typing — all keystrokes
        // processed back-to-back with zero delay. This should NOT
        // produce empty or corrupted output.

        let sessions = vec![
            // Normal typing
            ("xin chaof", "telex"),
            ("tooi laf nguwowif Vieetj", "telex"),
            ("hoanf toanf", "telex"),
            // VNI typing
            ("tie61ng Vie65t", "vni"),
            // Mixed (TeipVni)
            ("tie61ng vieejt", "teipvni"),
        ];

        for (text, method) in &sessions {
            // Type each character
            let mut input = String::new();
            for ch in text.chars() {
                input.push(ch);
                let output = match *method {
                    "vni" => engine::convert_vni(&input, false, true),
                    "teipvni" => engine::convert_teip_vni(&input, false, true),
                    _ => engine::convert_telex(&input, false, true),
                };
                // Output must not lose characters (except tone marks which
                // are consumed by the engine). At minimum, the output length
                // in chars should be >= input letters minus consumed markers.
                let input_letter_count = input.chars()
                    .filter(|c| c.is_ascii_alphabetic() || c.is_whitespace())
                    .count();
                let output_letter_count = output.chars()
                    .filter(|c| c.is_alphabetic() || c.is_whitespace())
                    .count();
                // Allow ~30% shrinkage (tone marks consumed, dd→đ, etc.)
                let min_expected = (input_letter_count as f64 * 0.6) as usize;
                if output_letter_count < min_expected && !input.trim().is_empty() {
                    println!("  ⚠ input='{}' ({}) → output='{}' ({})",
                        input, input_letter_count, output, output_letter_count);
                }
            }
        }
        println!("✓ Zero-delay typing completed");
    }
}