Skip to content

Commit 141d2b5

Browse files
authored
test(tui): add deterministic paste-burst tests (#9121)
Replace the old timing-dependent non-ASCII paste test with deterministic coverage by forcing an active `PasteBurst` and asserting the exact flush payload. Add focused unit tests for `PasteBurst` transitions, and add short "Behavior:" rustdoc notes on chat composer tests to make the state machine contracts explicit.
1 parent ebacd28 commit 141d2b5

File tree

4 files changed

+549
-85
lines changed

4 files changed

+549
-85
lines changed

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 143 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2437,6 +2437,8 @@ mod tests {
24372437
);
24382438
}
24392439

2440+
/// Behavior: `?` toggles the shortcut overlay only when the composer is otherwise empty. After
2441+
/// any typing has occurred, `?` should be inserted as a literal character.
24402442
#[test]
24412443
fn question_mark_only_toggles_on_first_char() {
24422444
use crossterm::event::KeyCode;
@@ -2484,6 +2486,8 @@ mod tests {
24842486
assert_eq!(composer.footer_mode(), FooterMode::ContextOnly);
24852487
}
24862488

2489+
/// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut
2490+
/// overlay; it should be treated as part of the pasted content.
24872491
#[test]
24882492
fn question_mark_does_not_toggle_during_paste_burst() {
24892493
use crossterm::event::KeyCode;
@@ -2699,6 +2703,9 @@ mod tests {
26992703
}
27002704
}
27012705

2706+
/// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII
2707+
/// char arrives next, the pending ASCII char should still be preserved and the overall input
2708+
/// should submit normally (i.e. we should not misclassify this as a paste burst).
27022709
#[test]
27032710
fn ascii_prefix_survives_non_ascii_followup() {
27042711
use crossterm::event::KeyCode;
@@ -2732,6 +2739,8 @@ mod tests {
27322739
}
27332740
}
27342741

2742+
/// Behavior: a single non-ASCII char should be inserted immediately (IME-friendly) and should
2743+
/// not create any paste-burst state.
27352744
#[test]
27362745
fn non_ascii_char_inserts_immediately_without_burst_state() {
27372746
use crossterm::event::KeyCode;
@@ -2758,55 +2767,126 @@ mod tests {
27582767
assert!(!composer.is_in_paste_burst());
27592768
}
27602769

2761-
// test a variety of non-ascii char sequences to ensure we are handling them correctly
2770+
/// Behavior: while we're capturing a paste-like burst, Enter should be treated as a newline
2771+
/// within the burst (not as "submit"), and the whole payload should flush as one paste.
27622772
#[test]
2763-
fn non_ascii_burst_handles_newline() {
2764-
let test_cases = [
2765-
// triggers on windows
2766-
"天地玄黄 宇宙洪荒
2767-
日月盈昃 辰宿列张
2768-
寒来暑往 秋收冬藏
2769-
2770-
你好世界 编码测试
2771-
汉字处理 UTF-8
2772-
终端显示 正确无误
2773-
2774-
风吹竹林 月照大江
2775-
白云千载 青山依旧
2776-
程序员 与 Unicode 同行",
2777-
// Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics.
2778-
"你 好\nhi",
2779-
];
2773+
fn non_ascii_burst_buffers_enter_and_flushes_multiline() {
2774+
use crossterm::event::KeyCode;
2775+
use crossterm::event::KeyEvent;
2776+
use crossterm::event::KeyModifiers;
27802777

2781-
for test_case in test_cases {
2782-
use crossterm::event::KeyCode;
2783-
use crossterm::event::KeyEvent;
2784-
use crossterm::event::KeyModifiers;
2778+
let (tx, _rx) = unbounded_channel::<AppEvent>();
2779+
let sender = AppEventSender::new(tx);
2780+
let mut composer = ChatComposer::new(
2781+
true,
2782+
sender,
2783+
false,
2784+
"Ask Codex to do anything".to_string(),
2785+
false,
2786+
);
27852787

2786-
let (tx, _rx) = unbounded_channel::<AppEvent>();
2787-
let sender = AppEventSender::new(tx);
2788-
let mut composer = ChatComposer::new(
2789-
true,
2790-
sender,
2791-
false,
2792-
"Ask Codex to do anything".to_string(),
2793-
false,
2794-
);
2788+
composer
2789+
.paste_burst
2790+
.begin_with_retro_grabbed(String::new(), Instant::now());
27952791

2796-
for c in test_case.chars() {
2797-
let _ =
2798-
composer.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
2799-
}
2792+
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE));
2793+
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE));
2794+
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2795+
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
2796+
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
28002797

2801-
assert!(
2802-
composer.textarea.text().is_empty(),
2803-
"non-empty textarea before flush: {test_case}",
2804-
);
2805-
let _ = flush_after_paste_burst(&mut composer);
2806-
assert_eq!(composer.textarea.text(), test_case);
2798+
assert!(composer.textarea.text().is_empty());
2799+
let _ = flush_after_paste_burst(&mut composer);
2800+
assert_eq!(composer.textarea.text(), "你好\nhi");
2801+
}
2802+
2803+
/// Behavior: a paste-like burst may include a full-width/ideographic space (U+3000). It should
2804+
/// still be captured as a single paste payload and preserve the exact Unicode content.
2805+
#[test]
2806+
fn non_ascii_burst_preserves_ideographic_space_and_ascii() {
2807+
use crossterm::event::KeyCode;
2808+
use crossterm::event::KeyEvent;
2809+
use crossterm::event::KeyModifiers;
2810+
2811+
let (tx, _rx) = unbounded_channel::<AppEvent>();
2812+
let sender = AppEventSender::new(tx);
2813+
let mut composer = ChatComposer::new(
2814+
true,
2815+
sender,
2816+
false,
2817+
"Ask Codex to do anything".to_string(),
2818+
false,
2819+
);
2820+
2821+
composer
2822+
.paste_burst
2823+
.begin_with_retro_grabbed(String::new(), Instant::now());
2824+
2825+
for ch in ['你', ' ', '好'] {
2826+
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
2827+
}
2828+
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2829+
for ch in ['h', 'i'] {
2830+
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
28072831
}
2832+
2833+
assert!(composer.textarea.text().is_empty());
2834+
let _ = flush_after_paste_burst(&mut composer);
2835+
assert_eq!(composer.textarea.text(), "你 好\nhi");
2836+
}
2837+
2838+
/// Behavior: a large multi-line payload containing both non-ASCII and ASCII (e.g. "UTF-8",
2839+
/// "Unicode") should be captured as a single paste-like burst, and Enter key events should
2840+
/// become `\n` within the buffered content.
2841+
#[test]
2842+
fn non_ascii_burst_buffers_large_multiline_mixed_ascii_and_unicode() {
2843+
use crossterm::event::KeyCode;
2844+
use crossterm::event::KeyEvent;
2845+
use crossterm::event::KeyModifiers;
2846+
2847+
const LARGE_MIXED_PAYLOAD: &str = "天地玄黄 宇宙洪荒\n\
2848+
日月盈昃 辰宿列张\n\
2849+
寒来暑往 秋收冬藏\n\
2850+
\n\
2851+
你好世界 编码测试\n\
2852+
汉字处理 UTF-8\n\
2853+
终端显示 正确无误\n\
2854+
\n\
2855+
风吹竹林 月照大江\n\
2856+
白云千载 青山依旧\n\
2857+
程序员 与 Unicode 同行";
2858+
2859+
let (tx, _rx) = unbounded_channel::<AppEvent>();
2860+
let sender = AppEventSender::new(tx);
2861+
let mut composer = ChatComposer::new(
2862+
true,
2863+
sender,
2864+
false,
2865+
"Ask Codex to do anything".to_string(),
2866+
false,
2867+
);
2868+
2869+
// Force an active burst so the test doesn't depend on timing heuristics.
2870+
composer
2871+
.paste_burst
2872+
.begin_with_retro_grabbed(String::new(), Instant::now());
2873+
2874+
for ch in LARGE_MIXED_PAYLOAD.chars() {
2875+
let code = if ch == '\n' {
2876+
KeyCode::Enter
2877+
} else {
2878+
KeyCode::Char(ch)
2879+
};
2880+
let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
2881+
}
2882+
2883+
assert!(composer.textarea.text().is_empty());
2884+
let _ = flush_after_paste_burst(&mut composer);
2885+
assert_eq!(composer.textarea.text(), LARGE_MIXED_PAYLOAD);
28082886
}
28092887

2888+
/// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a
2889+
/// newline into the buffered payload and flush as a single paste later.
28102890
#[test]
28112891
fn ascii_burst_treats_enter_as_newline() {
28122892
use crossterm::event::KeyCode;
@@ -2849,6 +2929,8 @@ mod tests {
28492929
assert_eq!(composer.textarea.text(), "hi\nthere");
28502930
}
28512931

2932+
/// Behavior: a small explicit paste inserts text directly (no placeholder), and the submitted
2933+
/// text matches what is visible in the textarea.
28522934
#[test]
28532935
fn handle_paste_small_inserts_text() {
28542936
use crossterm::event::KeyCode;
@@ -2911,6 +2993,8 @@ mod tests {
29112993
}
29122994
}
29132995

2996+
/// Behavior: a large explicit paste inserts a placeholder into the textarea, stores the full
2997+
/// content in `pending_pastes`, and expands the placeholder to the full content on submit.
29142998
#[test]
29152999
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
29163000
use crossterm::event::KeyCode;
@@ -2946,6 +3030,8 @@ mod tests {
29463030
assert!(composer.pending_pastes.is_empty());
29473031
}
29483032

3033+
/// Behavior: editing that removes a paste placeholder should also clear the associated
3034+
/// `pending_pastes` entry so it cannot be submitted accidentally.
29493035
#[test]
29503036
fn edit_clears_pending_paste() {
29513037
use crossterm::event::KeyCode;
@@ -3342,6 +3428,8 @@ mod tests {
33423428
assert_eq!(composer.textarea.text(), "@");
33433429
}
33443430

3431+
/// Behavior: multiple paste operations can coexist; placeholders should be expanded to their
3432+
/// original content on submission.
33453433
#[test]
33463434
fn test_multiple_pastes_submission() {
33473435
use crossterm::event::KeyCode;
@@ -3494,6 +3582,8 @@ mod tests {
34943582
);
34953583
}
34963584

3585+
/// Behavior: if multiple large pastes share the same placeholder label (same char count),
3586+
/// deleting one placeholder removes only its corresponding `pending_pastes` entry.
34973587
#[test]
34983588
fn deleting_duplicate_length_pastes_removes_only_target() {
34993589
use crossterm::event::KeyCode;
@@ -3531,6 +3621,8 @@ mod tests {
35313621
assert_eq!(composer.pending_pastes[0].1, paste);
35323622
}
35333623

3624+
/// Behavior: large-paste placeholder numbering does not get reused after deletion, so a new
3625+
/// paste of the same length gets a new unique placeholder label.
35343626
#[test]
35353627
fn large_paste_numbering_does_not_reuse_after_deletion() {
35363628
use crossterm::event::KeyCode;
@@ -3991,6 +4083,8 @@ mod tests {
39914083
assert!(composer.textarea.is_empty());
39924084
}
39934085

4086+
/// Behavior: selecting a custom prompt that includes a large paste placeholder should expand
4087+
/// to the full pasted content before submission.
39944088
#[test]
39954089
fn custom_prompt_with_large_paste_expands_correctly() {
39964090
use crossterm::event::KeyCode;
@@ -4410,6 +4504,8 @@ mod tests {
44104504
assert_eq!(InputResult::Submitted(expected), result);
44114505
}
44124506

4507+
/// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst
4508+
/// follows, it should eventually flush as normal typed input (not as a paste).
44134509
#[test]
44144510
fn pending_first_ascii_char_flushes_as_typed() {
44154511
use crossterm::event::KeyCode;
@@ -4437,6 +4533,8 @@ mod tests {
44374533
assert!(!composer.is_in_paste_burst());
44384534
}
44394535

4536+
/// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If
4537+
/// the payload is small, it should insert directly (no placeholder).
44404538
#[test]
44414539
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
44424540
use crossterm::event::KeyCode;
@@ -4480,6 +4578,8 @@ mod tests {
44804578
);
44814579
}
44824580

4581+
/// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If
4582+
/// the payload is large, it should insert a placeholder and defer the full text until submit.
44834583
#[test]
44844584
fn burst_paste_fast_large_inserts_placeholder_on_flush() {
44854585
use crossterm::event::KeyCode;
@@ -4515,6 +4615,8 @@ mod tests {
45154615
assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x'));
45164616
}
45174617

4618+
/// Behavior: human-like typing (with delays between chars) should not be classified as a paste
4619+
/// burst. Characters should appear immediately and should not trigger a paste placeholder.
45184620
#[test]
45194621
fn humanlike_typing_1000_chars_appears_live_no_placeholder() {
45204622
let (tx, _rx) = unbounded_channel::<AppEvent>();

0 commit comments

Comments
 (0)