test: add ime input regressions

This commit is contained in:
Ogulcan Celik 2026-05-12 15:54:19 +03:00
parent 8375922c06
commit 4af28fbb65
8 changed files with 201 additions and 17 deletions

View File

@ -1979,6 +1979,56 @@ mod tests {
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn route_client_input_forwards_multilingual_ime_text_to_focused_pane() {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
let focused = workspace.focused_pane_id().unwrap();
let text = "中日한🙂";
let (runtime, mut rx) =
PaneRuntime::test_with_channel_capacity(80, 24, text.chars().count());
workspace.tabs[0].runtimes.insert(focused, runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.route_client_input(text.as_bytes().to_vec());
let mut forwarded = Vec::new();
for _ in text.chars() {
let chunk = rx.recv().await.unwrap();
forwarded.extend_from_slice(&chunk);
}
assert_eq!(forwarded, text.as_bytes());
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn route_client_input_forwards_long_voice_like_cjk_text_without_truncation() {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
let focused = workspace.focused_pane_id().unwrap();
let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64);
let char_count = text.chars().count();
let (runtime, mut rx) = PaneRuntime::test_with_channel_capacity(80, 24, char_count);
workspace.tabs[0].runtimes.insert(focused, runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.route_client_input(text.as_bytes().to_vec());
let mut forwarded = Vec::new();
for _ in 0..char_count {
let chunk = rx.recv().await.unwrap();
forwarded.extend_from_slice(&chunk);
}
assert_eq!(forwarded, text.as_bytes());
assert!(rx.try_recv().is_err());
}
#[test]
fn route_client_input_handles_mouse_events() {
let mut app = test_app();

View File

@ -23,7 +23,7 @@ use std::time::Duration;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::execute;
@ -146,11 +146,7 @@ fn setup_terminal() -> io::Result<TerminalGuard> {
EnableMouseCapture,
EnableBracketedPaste,
EnableFocusChange,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
)?;
// tmux doesn't understand kitty keyboard protocol push.

View File

@ -6,5 +6,8 @@ mod parse;
pub use encode::{
encode_cursor_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
};
pub use model::{KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode, TerminalKey};
pub use model::{
ime_compatible_keyboard_enhancement_flags, KeyboardProtocol, MouseProtocolEncoding,
MouseProtocolMode, TerminalKey,
};
pub use parse::parse_terminal_key_sequence;

View File

@ -1,4 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, KeyboardEnhancementFlags};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalKey {
@ -40,6 +40,12 @@ impl From<KeyEvent> for TerminalKey {
}
}
pub fn ime_compatible_keyboard_enhancement_flags() -> KeyboardEnhancementFlags {
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyboardProtocol {
Legacy,
@ -97,4 +103,14 @@ mod tests {
KeyboardProtocol::Kitty { flags: 7 }
);
}
#[test]
fn keyboard_enhancement_flags_stay_ime_compatible() {
let flags = ime_compatible_keyboard_enhancement_flags();
assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS));
assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
}
}

View File

@ -2,7 +2,7 @@ use std::io;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::execute;
@ -454,11 +454,7 @@ fn main() -> io::Result<()> {
EnableMouseCapture,
EnableBracketedPaste,
EnableFocusChange,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
)?;
// tmux doesn't understand kitty keyboard protocol push (\e[>1u).

View File

@ -925,7 +925,15 @@ impl PaneRuntime {
#[cfg(test)]
impl PaneRuntime {
pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<Bytes>) {
Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[])
Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], 4)
}
pub(crate) fn test_with_channel_capacity(
cols: u16,
rows: u16,
capacity: usize,
) -> (Self, mpsc::Receiver<Bytes>) {
Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], capacity)
}
pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self {
@ -938,7 +946,7 @@ impl PaneRuntime {
scrollback_limit_bytes: usize,
bytes: &[u8],
) -> Self {
Self::test_with_channel_and_scrollback_bytes(cols, rows, scrollback_limit_bytes, bytes).0
Self::test_with_channel_and_scrollback_bytes(cols, rows, scrollback_limit_bytes, bytes, 4).0
}
fn test_with_channel_and_scrollback_bytes(
@ -946,8 +954,9 @@ impl PaneRuntime {
rows: u16,
scrollback_limit_bytes: usize,
bytes: &[u8],
channel_capacity: usize,
) -> (Self, mpsc::Receiver<Bytes>) {
let (tx, rx) = mpsc::channel(4);
let (tx, rx) = mpsc::channel(channel_capacity);
let (resize_tx, _resize_rx) = watch::channel((rows, cols));
let mut terminal =
crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes).unwrap();

View File

@ -1006,6 +1006,103 @@ mod tests {
);
}
#[test]
fn chunked_cjk_utf8_waits_for_all_continuation_bytes() {
let (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
let bytes = "".as_bytes();
drain_chunk(&mut buffer, &tx, &bytes[..1]);
assert_eq!(buffer, bytes[..1]);
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert_eq!(buffer, bytes[..1]);
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, &bytes[1..2]);
assert_eq!(buffer, bytes[..2]);
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert_eq!(buffer, bytes[..2]);
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, &bytes[2..]);
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('好'),
KeyModifiers::empty(),
);
}
#[test]
fn chunked_four_byte_utf8_waits_for_all_continuation_bytes() {
let (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
let bytes = "🙂".as_bytes();
for split in 1..bytes.len() {
drain_chunk(&mut buffer, &tx, &bytes[split - 1..split]);
assert_eq!(buffer, bytes[..split]);
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert_eq!(buffer, bytes[..split]);
assert!(collect_events(&mut rx).is_empty());
}
drain_chunk(&mut buffer, &tx, &bytes[bytes.len() - 1..]);
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('🙂'),
KeyModifiers::empty(),
);
}
#[test]
fn long_multilingual_voice_like_burst_drains_without_truncation() {
let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(128);
assert!(
text.len() > 4096,
"test input should exceed the client read buffer"
);
let mut buffer = text.as_bytes().to_vec();
let chunks = drain_complete_input_bytes(&mut buffer);
let rebuilt: Vec<u8> = chunks.into_iter().flatten().collect();
assert!(buffer.is_empty());
assert_eq!(rebuilt, text.as_bytes());
}
#[test]
fn long_multilingual_burst_survives_one_byte_chunks_and_timeouts() {
let text = "中文かなカナ한글🙂,。".repeat(64);
let mut buffer = Vec::new();
let mut rebuilt = Vec::new();
for byte in text.as_bytes() {
buffer.push(*byte);
for chunk in drain_complete_input_bytes(&mut buffer) {
rebuilt.extend(chunk);
}
if !buffer.is_empty() {
assert_eq!(flush_incomplete_input_bytes(&mut buffer), None);
}
}
for chunk in drain_complete_input_bytes(&mut buffer) {
rebuilt.extend(chunk);
}
assert!(buffer.is_empty());
assert_eq!(rebuilt, text.as_bytes());
}
#[test]
fn parse_with_ranges_tracks_byte_offsets() {
use super::parse_raw_input_bytes_with_ranges;

View File

@ -565,6 +565,23 @@ mod tests {
assert_eq!(msg, decoded);
}
#[test]
fn client_input_large_multilingual_payload_roundtrip() {
let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(1024);
assert!(text.len() > 64 * 1024);
assert!(text.len() < MAX_FRAME_SIZE);
let msg = ClientMessage::Input {
data: text.as_bytes().to_vec(),
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, consumed): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(consumed, encoded.len());
assert_eq!(decoded, msg);
}
#[test]
fn client_resize_roundtrip() {
let msg = ClientMessage::Resize { cols: 80, rows: 24 };