diff --git a/README.md b/README.md index 9e76ffe..dcf7812 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Options: --no-backtrack Disable backtracking to completed words --sudden-death Enable sudden death mode to restart on first error --case-insensitive Ignore case when comparing typed input + --no-backspace Disable backspace/delete during test --history Show history of past results --last Show only the last N history entries --history-lang Filter history by language diff --git a/src/main.rs b/src/main.rs index 14772c8..be36784 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,10 @@ struct Opt { #[arg(long)] case_insensitive: bool, + /// Disable backspace/delete during test + #[arg(long)] + no_backspace: bool, + /// Show history of past results #[arg(long)] history: bool, @@ -387,6 +391,7 @@ fn main() -> io::Result<()> { !opt.no_backtrack, opt.sudden_death, opt.case_insensitive, + opt.no_backspace, )); state.render_into(&mut terminal, &config)?; @@ -436,6 +441,7 @@ fn main() -> io::Result<()> { !opt.no_backtrack, opt.sudden_death, opt.case_insensitive, + opt.no_backspace, )); } _ => continue, @@ -470,6 +476,7 @@ fn main() -> io::Result<()> { !opt.no_backtrack, opt.sudden_death, opt.case_insensitive, + opt.no_backspace, )); } _ => continue, @@ -494,6 +501,7 @@ fn main() -> io::Result<()> { !opt.no_backtrack, opt.sudden_death, opt.case_insensitive, + opt.no_backspace, )); } Event::Key(KeyEvent { @@ -510,6 +518,7 @@ fn main() -> io::Result<()> { !opt.no_backtrack, opt.sudden_death, opt.case_insensitive, + opt.no_backspace, )); } Event::Key(KeyEvent { @@ -532,6 +541,7 @@ fn main() -> io::Result<()> { !opt.no_backtrack, opt.sudden_death, opt.case_insensitive, + opt.no_backspace, )); } Event::Key(KeyEvent { diff --git a/src/test/mod.rs b/src/test/mod.rs index fed423a..6c099b7 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -56,6 +56,7 @@ pub struct Test { pub backtracking_enabled: bool, pub sudden_death_enabled: bool, pub case_insensitive: bool, + pub no_backspace: bool, pending_presses: HashMap, } @@ -65,6 +66,7 @@ impl Test { backtracking_enabled: bool, sudden_death_enabled: bool, case_insensitive: bool, + no_backspace: bool, ) -> Self { Self { words: words.into_iter().map(TestWord::from).collect(), @@ -73,6 +75,7 @@ impl Test { backtracking_enabled, sudden_death_enabled, case_insensitive, + no_backspace, pending_presses: HashMap::new(), } } @@ -121,7 +124,7 @@ impl Test { } } } - KeyCode::Backspace => { + KeyCode::Backspace if !self.no_backspace => { if word.progress.is_empty() && self.backtracking_enabled { self.last_word(); } else { @@ -143,7 +146,9 @@ impl Test { } } // CTRL-H → delete single character (same as Backspace) - KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('h') + if key.modifiers.contains(KeyModifiers::CONTROL) && !self.no_backspace => + { if word.progress.is_empty() && self.backtracking_enabled { self.last_word(); } else { @@ -165,7 +170,9 @@ impl Test { } } // CTRL-W and CTRL-Backspace → delete entire word - KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('w') + if key.modifiers.contains(KeyModifiers::CONTROL) && !self.no_backspace => + { if self.words[self.current_word].progress.is_empty() { self.last_word(); } @@ -295,7 +302,7 @@ mod tests { #[test] fn ctrl_h_deletes_single_character() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); type_string(&mut test, "hel"); assert_eq!(test.words[0].progress, "hel"); @@ -313,6 +320,7 @@ mod tests { true, // backtracking enabled false, false, + false, ); // Complete word 1, move to word 2 type_string(&mut test, "ab"); @@ -334,6 +342,7 @@ mod tests { false, // backtracking disabled false, false, + false, ); type_string(&mut test, "ab"); test.handle_key(press(KeyCode::Char(' '))); @@ -349,7 +358,7 @@ mod tests { #[test] fn ctrl_letter_is_ignored() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); type_string(&mut test, "he"); assert_eq!(test.words[0].progress, "he"); @@ -370,7 +379,7 @@ mod tests { #[test] fn ctrl_letter_no_event_added() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); type_string(&mut test, "he"); let events_before = test.words[0].events.len(); @@ -384,7 +393,7 @@ mod tests { #[test] fn shift_letter_still_types() { - let mut test = Test::new(vec!["Hello".to_string()], true, false, false); + let mut test = Test::new(vec!["Hello".to_string()], true, false, false, false); let shift_h = KeyEvent { code: KeyCode::Char('H'), @@ -401,7 +410,7 @@ mod tests { #[test] fn ctrl_shift_letter_is_ignored() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); type_string(&mut test, "he"); let ctrl_shift_a = KeyEvent { @@ -419,7 +428,13 @@ mod tests { #[test] fn ctrl_space_does_not_advance_word() { - let mut test = Test::new(vec!["ab".to_string(), "cd".to_string()], true, false, false); + let mut test = Test::new( + vec!["ab".to_string(), "cd".to_string()], + true, + false, + false, + false, + ); type_string(&mut test, "ab"); assert_eq!(test.current_word, 0); @@ -433,7 +448,7 @@ mod tests { #[test] fn tab_does_not_affect_progress() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); type_string(&mut test, "he"); test.handle_key(press(KeyCode::Tab)); @@ -446,7 +461,7 @@ mod tests { #[test] fn ctrl_w_still_clears_entire_word() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); type_string(&mut test, "hel"); assert_eq!(test.words[0].progress, "hel"); @@ -459,7 +474,7 @@ mod tests { #[test] fn case_insensitive_lowercase_matches_uppercase_word() { - let mut test = Test::new(vec!["Hello".to_string()], true, false, true); + let mut test = Test::new(vec!["Hello".to_string()], true, false, true, false); type_string(&mut test, "hello"); assert_eq!( test.words[0].progress, "hello", @@ -475,7 +490,7 @@ mod tests { #[test] fn case_insensitive_uppercase_matches_lowercase_word() { - let mut test = Test::new(vec!["hello".to_string()], true, false, true); + let mut test = Test::new(vec!["hello".to_string()], true, false, true, false); let shift_h = KeyEvent { code: KeyCode::Char('H'), modifiers: KeyModifiers::SHIFT, @@ -492,7 +507,7 @@ mod tests { #[test] fn case_insensitive_correct_flag_on_events() { - let mut test = Test::new(vec!["World".to_string()], true, false, true); + let mut test = Test::new(vec!["World".to_string()], true, false, true, false); type_string(&mut test, "world"); // All events should be marked correct (case-insensitive comparison) assert!( @@ -503,7 +518,7 @@ mod tests { #[test] fn case_sensitive_uppercase_mismatch() { - let mut test = Test::new(vec!["Hello".to_string()], true, false, false); + let mut test = Test::new(vec!["Hello".to_string()], true, false, false, false); type_string(&mut test, "hello"); test.handle_key(press(KeyCode::Char(' '))); // In case-sensitive mode, 'hello' != 'Hello', so the word event should be incorrect @@ -517,11 +532,59 @@ mod tests { #[test] fn case_insensitive_auto_complete_last_word() { - let mut test = Test::new(vec!["ABC".to_string()], true, false, true); + let mut test = Test::new(vec!["ABC".to_string()], true, false, true, false); type_string(&mut test, "abc"); assert!( test.complete, "Typing 'abc' for last word 'ABC' should auto-complete in case-insensitive mode" ); } + + #[test] + fn no_backspace_blocks_backspace() { + let mut test = Test::new(vec!["hello".to_string()], true, false, false, true); + type_string(&mut test, "hel"); + assert_eq!(test.words[0].progress, "hel"); + + test.handle_key(press(KeyCode::Backspace)); + assert_eq!( + test.words[0].progress, "hel", + "Backspace should be ignored when no_backspace is enabled" + ); + } + + #[test] + fn no_backspace_blocks_ctrl_h() { + let mut test = Test::new(vec!["hello".to_string()], true, false, false, true); + type_string(&mut test, "hel"); + + test.handle_key(press_ctrl(KeyCode::Char('h'))); + assert_eq!( + test.words[0].progress, "hel", + "Ctrl+H should be ignored when no_backspace is enabled" + ); + } + + #[test] + fn no_backspace_blocks_ctrl_w() { + let mut test = Test::new(vec!["hello".to_string()], true, false, false, true); + type_string(&mut test, "hel"); + + test.handle_key(press_ctrl(KeyCode::Char('w'))); + assert_eq!( + test.words[0].progress, "hel", + "Ctrl+W should be ignored when no_backspace is enabled" + ); + } + + #[test] + fn no_backspace_still_allows_typing() { + let mut test = Test::new(vec!["hi".to_string()], true, false, false, true); + type_string(&mut test, "hi"); + test.handle_key(press(KeyCode::Char(' '))); + assert!( + test.complete, + "Normal typing and word completion should still work with no_backspace" + ); + } } diff --git a/src/test/results.rs b/src/test/results.rs index 69f789d..82adc0d 100644 --- a/src/test/results.rs +++ b/src/test/results.rs @@ -266,7 +266,7 @@ mod tests { #[test] fn non_target_key_excluded_from_per_key() { - let mut test = Test::new(vec!["abc".to_string()], true, false, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('x', false)); // 'x' not in "abc" test.words[0].events.push(make_event('b', true)); @@ -288,7 +288,7 @@ mod tests { #[test] fn non_target_key_still_counted_in_overall() { - let mut test = Test::new(vec!["ab".to_string()], true, false, false); + let mut test = Test::new(vec!["ab".to_string()], true, false, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('x', false)); // wrong key, not in target test.words[0].events.push(make_event('b', true)); @@ -302,7 +302,7 @@ mod tests { #[test] fn target_key_with_errors_tracked_correctly() { - let mut test = Test::new(vec!["aa".to_string()], true, false, false); + let mut test = Test::new(vec!["aa".to_string()], true, false, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('a', false)); // 'a' is in target but typed wrong position @@ -316,7 +316,7 @@ mod tests { #[test] fn shift_variant_of_target_key_tracked() { // Target has lowercase 'e', user types uppercase 'E' (Shift mistake) - let mut test = Test::new(vec!["hello".to_string()], true, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); test.words[0].events.push(make_event('h', true)); test.words[0].events.push(make_event('E', false)); // Shift-variant of 'e' test.words[0].events.push(make_event('l', true)); @@ -332,7 +332,7 @@ mod tests { #[test] fn multiple_non_target_keys_all_excluded() { - let mut test = Test::new(vec!["a".to_string()], true, false, false); + let mut test = Test::new(vec!["a".to_string()], true, false, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('x', false)); test.words[0].events.push(make_event('y', false)); @@ -381,6 +381,7 @@ mod tests { true, false, false, + false, ); // "fast" — 4 chars in 0.4s = 0.1s/char @@ -424,6 +425,7 @@ mod tests { true, false, false, + false, ); // "correct" — typed correctly @@ -460,6 +462,7 @@ mod tests { true, false, false, + false, ); // "a" — only 1 event (can't measure timing) @@ -483,7 +486,7 @@ mod tests { fn slow_words_caps_at_five() { let now = Instant::now(); let words: Vec = (0..10).map(|i| format!("word{}", i)).collect(); - let mut test = Test::new(words, true, false, false); + let mut test = Test::new(words, true, false, false, false); for (wi, word) in test.words.iter_mut().enumerate() { for (ci, c) in word.text.clone().chars().enumerate() { @@ -502,7 +505,7 @@ mod tests { #[test] fn results_preserve_word_list() { let words = vec!["hello".to_string(), "world".to_string(), "test".to_string()]; - let test = Test::new(words.clone(), true, false, false); + let test = Test::new(words.clone(), true, false, false, false); let results = Results::from(&test); @@ -519,7 +522,7 @@ mod tests { "apple".to_string(), "mango".to_string(), ]; - let test = Test::new(words.clone(), true, false, false); + let test = Test::new(words.clone(), true, false, false, false); let results = Results::from(&test); @@ -535,7 +538,7 @@ mod tests { #[test] fn dwell_no_release_events() { - let mut test = Test::new(vec!["abc".to_string()], true, false, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('b', true)); test.words[0].events.push(make_event('c', true)); @@ -552,7 +555,7 @@ mod tests { #[test] fn dwell_with_release_events() { let now = Instant::now(); - let mut test = Test::new(vec!["ab".to_string()], true, false, false); + let mut test = Test::new(vec!["ab".to_string()], true, false, false, false); // 'a' held for 80ms, 'b' held for 120ms test.words[0].events.push(make_dwell_event( @@ -581,7 +584,7 @@ mod tests { #[test] fn dwell_mixed_events() { let now = Instant::now(); - let mut test = Test::new(vec!["abc".to_string()], true, false, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false, false); // 'a' has release (100ms), 'b' does not, 'c' has release (50ms) test.words[0].events.push(make_dwell_event( @@ -614,7 +617,7 @@ mod tests { #[test] fn dwell_per_key_averages() { let now = Instant::now(); - let mut test = Test::new(vec!["aa".to_string()], true, false, false); + let mut test = Test::new(vec!["aa".to_string()], true, false, false, false); // Two presses of 'a': 60ms and 100ms → avg 80ms test.words[0].events.push(make_dwell_event(