use super::super::*;
use crate::test_utils::test_helpers::{key, key_with_mods, test_app};
use proptest::prelude::*;
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use std::sync::Arc;
#[test]
fn test_navigation_scrolls_to_match() {
let mut app = test_app(r#"{"name": "test"}"#);
let content: String = (0..30)
.map(|i| {
if i == 0 || i == 10 || i == 20 {
format!("match line {}\n", i)
} else {
format!("other line {}\n", i)
}
})
.collect();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new(content.clone()));
app.results_scroll.viewport_height = 10;
app.results_scroll.max_offset = 20;
app.results_scroll.offset = 0;
open_search(&mut app);
app.search.search_textarea_mut().insert_str("match");
app.search.update_matches(&content);
assert_eq!(app.search.matches().len(), 3);
assert_eq!(app.search.matches()[0].line, 0);
assert_eq!(app.search.matches()[1].line, 10);
assert_eq!(app.search.matches()[2].line, 20);
handle_search_key(&mut app, key(KeyCode::Enter));
assert!(app.search.is_confirmed());
assert_eq!(app.search.current_index(), 0);
assert_eq!(
app.results_scroll.offset, 0,
"Scroll should be at top for match at line 0"
);
handle_search_key(&mut app, key(KeyCode::Char('n')));
assert_eq!(app.search.current_index(), 1);
assert_eq!(
app.results_scroll.offset, 6,
"Scroll should position match at line 10 with bottom margin"
);
handle_search_key(&mut app, key(KeyCode::Char('n')));
assert_eq!(app.search.current_index(), 2);
assert_eq!(
app.results_scroll.offset, 16,
"Scroll should position match at line 20 with bottom margin"
);
handle_search_key(&mut app, key(KeyCode::Char('n')));
assert_eq!(app.search.current_index(), 0);
assert_eq!(
app.results_scroll.offset, 0,
"Scroll should be at top for match at line 0"
);
}
#[test]
fn test_n_navigates_to_next_match() {
let mut app = test_app(r#"{"name": "test"}"#);
app.query.as_mut().unwrap().last_successful_result =
Some(Arc::new("test\ntest\ntest".to_string()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches("test\ntest\ntest");
assert_eq!(app.search.matches().len(), 3);
assert_eq!(app.search.current_index(), 0);
handle_search_key(&mut app, key(KeyCode::Enter));
assert!(app.search.is_confirmed());
assert_eq!(app.search.current_index(), 0);
handle_search_key(&mut app, key(KeyCode::Char('n')));
assert_eq!(app.search.current_index(), 1);
handle_search_key(&mut app, key(KeyCode::Char('n')));
assert_eq!(app.search.current_index(), 2);
}
#[test]
fn test_capital_n_navigates_to_prev_match() {
let mut app = test_app(r#"{"name": "test"}"#);
app.query.as_mut().unwrap().last_successful_result =
Some(Arc::new("test\ntest\ntest".to_string()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches("test\ntest\ntest");
handle_search_key(&mut app, key(KeyCode::Enter));
assert!(app.search.is_confirmed());
assert_eq!(app.search.current_index(), 0);
handle_search_key(&mut app, key(KeyCode::Char('N')));
assert_eq!(app.search.current_index(), 2);
}
#[test]
fn test_enter_navigates_to_next_match() {
let mut app = test_app(r#"{"name": "test"}"#);
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new("test\ntest".to_string()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches("test\ntest");
handle_search_key(&mut app, key(KeyCode::Enter));
assert_eq!(app.search.current_index(), 0);
assert!(app.search.is_confirmed());
handle_search_key(&mut app, key(KeyCode::Enter));
assert_eq!(app.search.current_index(), 1);
}
#[test]
fn test_shift_enter_navigates_to_prev_match() {
let mut app = test_app(r#"{"name": "test"}"#);
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new("test\ntest".to_string()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches("test\ntest");
handle_search_key(&mut app, key_with_mods(KeyCode::Enter, KeyModifiers::SHIFT));
assert_eq!(app.search.current_index(), 0);
assert!(app.search.is_confirmed());
handle_search_key(&mut app, key_with_mods(KeyCode::Enter, KeyModifiers::SHIFT));
assert_eq!(app.search.current_index(), 1);
}
#[test]
fn test_ctrl_f_reenters_edit_mode_when_confirmed() {
let mut app = test_app(r#"{"name": "test"}"#);
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new("test\ntest".to_string()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new("test\ntest".to_string()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches("test\ntest");
handle_search_key(&mut app, key(KeyCode::Enter));
assert!(app.search.is_confirmed());
handle_search_key(
&mut app,
key_with_mods(KeyCode::Char('f'), KeyModifiers::CONTROL),
);
assert!(!app.search.is_confirmed());
assert!(app.search.is_visible());
assert_eq!(app.search.query(), "test");
}
#[test]
fn test_slash_reenters_edit_mode_when_confirmed() {
let mut app = test_app(r#"{"name": "test"}"#);
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new("test\ntest".to_string()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new("test\ntest".to_string()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches("test\ntest");
handle_search_key(&mut app, key(KeyCode::Enter));
assert!(app.search.is_confirmed());
handle_search_key(&mut app, key(KeyCode::Char('/')));
assert!(!app.search.is_confirmed());
assert!(app.search.is_visible());
assert_eq!(app.search.query(), "test");
}
#[test]
fn test_can_type_after_reenter_edit_mode() {
let mut app = test_app(r#"{"name": "test"}"#);
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new("test\ntest".to_string()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new("test\ntest".to_string()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches("test\ntest");
handle_search_key(&mut app, key(KeyCode::Enter));
assert!(app.search.is_confirmed());
handle_search_key(
&mut app,
key_with_mods(KeyCode::Char('f'), KeyModifiers::CONTROL),
);
assert!(!app.search.is_confirmed());
handle_search_key(&mut app, key(KeyCode::Char('2')));
assert_eq!(app.search.query(), "test2");
}
#[test]
fn test_tab_confirms_search_when_not_confirmed() {
let mut app = test_app(r#"{"name": "test"}"#);
let content = "test\ntest\ntest".to_string();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new(content.clone()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches(&content);
assert!(!app.search.is_confirmed());
assert_eq!(app.search.current_index(), 0);
handle_search_key(&mut app, key(KeyCode::Tab));
assert!(app.search.is_confirmed());
assert_eq!(app.search.current_index(), 0);
}
#[test]
fn test_tab_unconfirms_search_when_confirmed() {
use crate::app::Focus;
let mut app = test_app(r#"{"name": "test"}"#);
let content = "test\ntest\ntest".to_string();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new(content.clone()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches(&content);
handle_search_key(&mut app, key(KeyCode::Enter));
assert!(app.search.is_confirmed());
assert_eq!(app.focus, Focus::ResultsPane);
handle_search_key(&mut app, key(KeyCode::Tab));
assert!(!app.search.is_confirmed());
assert!(app.search.is_visible());
assert_eq!(app.search.query(), "test");
assert_eq!(app.focus, Focus::ResultsPane);
}
#[test]
fn test_tab_cycles_between_edit_and_navigation_mode() {
let mut app = test_app(r#"{"name": "test"}"#);
let content = "test\ntest\ntest".to_string();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new(content.clone()));
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches(&content);
assert!(!app.search.is_confirmed());
handle_search_key(&mut app, key(KeyCode::Tab));
assert!(app.search.is_confirmed());
handle_search_key(&mut app, key(KeyCode::Tab));
assert!(!app.search.is_confirmed());
handle_search_key(&mut app, key(KeyCode::Tab));
assert!(app.search.is_confirmed());
assert_eq!(app.search.query(), "test");
}
#[test]
fn test_tab_scrolls_to_current_match_when_confirming() {
let mut app = test_app(r#"{"name": "test"}"#);
let content: String = (0..30)
.map(|i| {
if i == 0 || i == 15 {
format!("match line {}\n", i)
} else {
format!("other line {}\n", i)
}
})
.collect();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new(content.clone()));
app.results_scroll.viewport_height = 10;
app.results_scroll.max_offset = 20;
app.results_scroll.offset = 0;
open_search(&mut app);
app.search.search_textarea_mut().insert_str("match");
app.search.update_matches(&content);
assert_eq!(app.search.matches().len(), 2);
assert_eq!(app.search.matches()[0].line, 0);
handle_search_key(&mut app, key(KeyCode::Tab));
assert!(app.search.is_confirmed());
assert_eq!(
app.results_scroll.offset, 0,
"Should scroll to first match at line 0"
);
}
fn app_with_confirmed_search() -> crate::app::App {
let mut app = test_app(r#"{"name": "test"}"#);
let content: String = (0..50).map(|i| format!("line {} test\n", i)).collect();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query
.as_mut()
.unwrap()
.last_successful_result_unformatted = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().result = Ok(content.clone());
app.results_scroll.update_bounds(50, 10);
app.results_scroll.update_h_bounds(100, 40);
app.results_scroll.offset = 0;
app.results_scroll.h_offset = 0;
app.results_cursor.update_total_lines(50);
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches(&content);
handle_search_key(&mut app, key(KeyCode::Enter));
assert!(app.search.is_confirmed());
app
}
#[test]
fn test_j_moves_cursor_down_when_search_confirmed() {
let mut app = app_with_confirmed_search();
handle_search_key(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.results_cursor.cursor_line(), 1);
assert!(app.search.is_confirmed(), "Search should remain confirmed");
}
#[test]
fn test_k_moves_cursor_up_when_search_confirmed() {
let mut app = app_with_confirmed_search();
app.results_cursor.move_to_line(10);
handle_search_key(&mut app, key(KeyCode::Char('k')));
assert_eq!(app.results_cursor.cursor_line(), 9);
assert!(app.search.is_confirmed(), "Search should remain confirmed");
}
#[test]
fn test_h_scrolls_left_when_search_confirmed() {
let mut app = app_with_confirmed_search();
app.results_scroll.h_offset = 10;
handle_search_key(&mut app, key(KeyCode::Char('h')));
assert_eq!(app.results_scroll.h_offset, 9);
assert!(app.search.is_confirmed(), "Search should remain confirmed");
}
#[test]
fn test_l_scrolls_right_when_search_confirmed() {
let mut app = app_with_confirmed_search();
app.results_scroll.h_offset = 0;
handle_search_key(&mut app, key(KeyCode::Char('l')));
assert_eq!(app.results_scroll.h_offset, 1);
assert!(app.search.is_confirmed(), "Search should remain confirmed");
}
#[test]
fn test_g_jumps_to_top_when_search_confirmed() {
let mut app = app_with_confirmed_search();
app.results_cursor.move_to_line(30);
app.results_scroll.offset = 20;
handle_search_key(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.results_cursor.cursor_line(), 0);
assert_eq!(app.results_scroll.offset, 0);
assert!(app.search.is_confirmed(), "Search should remain confirmed");
}
#[test]
fn test_capital_g_jumps_to_bottom_when_search_confirmed() {
let mut app = app_with_confirmed_search();
handle_search_key(&mut app, key(KeyCode::Char('G')));
assert_eq!(app.results_cursor.cursor_line(), 49);
assert!(app.search.is_confirmed(), "Search should remain confirmed");
}
#[test]
fn test_ctrl_d_page_down_when_search_confirmed() {
let mut app = app_with_confirmed_search();
handle_search_key(
&mut app,
key_with_mods(KeyCode::Char('d'), KeyModifiers::CONTROL),
);
assert_eq!(app.results_cursor.cursor_line(), 5);
assert!(app.search.is_confirmed(), "Search should remain confirmed");
}
#[test]
fn test_ctrl_u_page_up_when_search_confirmed() {
let mut app = app_with_confirmed_search();
app.results_cursor.move_to_line(20);
handle_search_key(
&mut app,
key_with_mods(KeyCode::Char('u'), KeyModifiers::CONTROL),
);
assert_eq!(app.results_cursor.cursor_line(), 15);
assert!(app.search.is_confirmed(), "Search should remain confirmed");
}
#[test]
fn test_navigation_preserves_match_index() {
let mut app = app_with_confirmed_search();
handle_search_key(&mut app, key(KeyCode::Char('n')));
let match_index_before = app.search.current_index();
handle_search_key(&mut app, key(KeyCode::Char('j')));
handle_search_key(&mut app, key(KeyCode::Char('k')));
handle_search_key(&mut app, key(KeyCode::Char('l')));
handle_search_key(&mut app, key(KeyCode::Char('h')));
assert_eq!(app.search.current_index(), match_index_before);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_ctrl_f_opens_search_from_any_pane(
focus_on_input in any::<bool>(),
) {
use crate::app::Focus;
let mut app = test_app(r#"{"name": "test"}"#);
app.focus = if focus_on_input {
Focus::InputField
} else {
Focus::ResultsPane
};
assert!(!app.search.is_visible());
open_search(&mut app);
prop_assert!(
app.search.is_visible(),
"Search should be visible after Ctrl+F"
);
prop_assert_eq!(
app.focus, Focus::ResultsPane,
"Focus should be on results pane after Ctrl+F"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_slash_opens_search_only_from_results_pane(
focus_on_input in any::<bool>(),
) {
use crate::app::Focus;
let mut app = test_app(r#"{"name": "test"}"#);
let initial_focus = if focus_on_input {
Focus::InputField
} else {
Focus::ResultsPane
};
app.focus = initial_focus;
assert!(!app.search.is_visible());
if initial_focus == Focus::ResultsPane {
open_search(&mut app);
prop_assert!(
app.search.is_visible(),
"Search should be visible after '/' in results pane"
);
} else {
prop_assert!(
!app.search.is_visible(),
"Search should NOT be visible when '/' pressed in input field"
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_vertical_navigation_scrolls_when_search_confirmed(
viewport_height in 5u16..50,
max_offset in 10u16..200,
initial_cursor_factor in 0.0f64..1.0,
key_type in 0u8..10,
) {
let mut app = test_app(r#"{"name": "test"}"#);
let content_lines = max_offset as u32 + viewport_height as u32;
let content: String = (0..content_lines)
.map(|i| format!("line {} test\n", i))
.collect();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().last_successful_result_unformatted = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().result = Ok(content.clone());
app.results_scroll.update_bounds(content_lines, viewport_height);
app.results_cursor.update_total_lines(content_lines);
let max_cursor = content_lines.saturating_sub(1);
let initial_cursor = ((initial_cursor_factor * max_cursor as f64) as u32).min(max_cursor);
app.results_cursor.move_to_line(initial_cursor);
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches(&content);
handle_search_key(&mut app, key(KeyCode::Enter));
prop_assert!(app.search.is_confirmed(), "Search should be confirmed");
let cursor_before = app.results_cursor.cursor_line();
let test_key = match key_type {
0 => key(KeyCode::Char('j')),
1 => key(KeyCode::Char('k')),
2 => key(KeyCode::Char('J')),
3 => key(KeyCode::Char('K')),
4 => key(KeyCode::Char('g')),
5 => key(KeyCode::Char('G')),
6 => key(KeyCode::Up),
7 => key(KeyCode::Down),
8 => key(KeyCode::Home),
9 => key(KeyCode::End),
_ => key(KeyCode::Char('j')),
};
handle_search_key(&mut app, test_key);
let expected_cursor = match key_type {
0 | 7 => cursor_before.saturating_add(1).min(max_cursor),
1 | 6 => cursor_before.saturating_sub(1),
2 => cursor_before.saturating_add(10).min(max_cursor),
3 => cursor_before.saturating_sub(10),
4 | 8 => 0,
5 | 9 => max_cursor,
_ => cursor_before,
};
prop_assert_eq!(
app.results_cursor.cursor_line(), expected_cursor,
"Vertical navigation key {} should change cursor from {} to {}, got {}",
key_type, cursor_before, expected_cursor, app.results_cursor.cursor_line()
);
prop_assert!(app.search.is_confirmed(), "Search should remain confirmed after navigation");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_horizontal_navigation_scrolls_when_search_confirmed(
viewport_width in 20u16..80,
max_h_offset in 20u16..200,
initial_h_offset_factor in 0.0f64..1.0,
key_type in 0u8..7,
) {
let mut app = test_app(r#"{"name": "test"}"#);
let line_width = max_h_offset + viewport_width;
let content: String = (0..20)
.map(|i| format!("line {} test {}\n", i, "x".repeat(line_width as usize)))
.collect();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().last_successful_result_unformatted = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().result = Ok(content.clone());
app.results_scroll.update_bounds(20, 10);
app.results_scroll.update_h_bounds(line_width, viewport_width);
let initial_h_offset = ((initial_h_offset_factor * max_h_offset as f64) as u16).min(max_h_offset);
app.results_scroll.h_offset = initial_h_offset;
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches(&content);
handle_search_key(&mut app, key(KeyCode::Enter));
prop_assert!(app.search.is_confirmed(), "Search should be confirmed");
let h_offset_before = app.results_scroll.h_offset;
let test_key = match key_type {
0 => key(KeyCode::Char('h')),
1 => key(KeyCode::Char('l')),
2 => key(KeyCode::Char('H')),
3 => key(KeyCode::Char('L')),
4 => key(KeyCode::Char('0')),
5 => key(KeyCode::Char('^')),
6 => key(KeyCode::Left),
_ => key(KeyCode::Char('h')),
};
handle_search_key(&mut app, test_key);
let expected_h_offset = match key_type {
0 | 6 => h_offset_before.saturating_sub(1),
1 => h_offset_before.saturating_add(1).min(max_h_offset),
2 => h_offset_before.saturating_sub(10),
3 => h_offset_before.saturating_add(10).min(max_h_offset),
4 | 5 => 0,
_ => h_offset_before,
};
prop_assert_eq!(
app.results_scroll.h_offset, expected_h_offset,
"Horizontal navigation key {} should change h_offset from {} to {}, got {}",
key_type, h_offset_before, expected_h_offset, app.results_scroll.h_offset
);
prop_assert!(app.search.is_confirmed(), "Search should remain confirmed after navigation");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_page_scroll_when_search_confirmed(
viewport_height in 10u16..50,
max_offset in 20u16..200,
initial_cursor_factor in 0.0f64..1.0,
key_type in 0u8..4,
) {
let mut app = test_app(r#"{"name": "test"}"#);
let content_lines = max_offset as u32 + viewport_height as u32;
let content: String = (0..content_lines)
.map(|i| format!("line {} test\n", i))
.collect();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().last_successful_result_unformatted = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().result = Ok(content.clone());
app.results_scroll.update_bounds(content_lines, viewport_height);
app.results_cursor.update_total_lines(content_lines);
let max_cursor = content_lines.saturating_sub(1);
let initial_cursor = ((initial_cursor_factor * max_cursor as f64) as u32).min(max_cursor);
app.results_cursor.move_to_line(initial_cursor);
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches(&content);
handle_search_key(&mut app, key(KeyCode::Enter));
prop_assert!(app.search.is_confirmed(), "Search should be confirmed");
let cursor_before = app.results_cursor.cursor_line();
let half_page = viewport_height / 2;
let test_key = match key_type {
0 => key_with_mods(KeyCode::Char('d'), KeyModifiers::CONTROL),
1 => key_with_mods(KeyCode::Char('u'), KeyModifiers::CONTROL),
2 => key(KeyCode::PageDown),
3 => key(KeyCode::PageUp),
_ => key(KeyCode::PageDown),
};
handle_search_key(&mut app, test_key);
let expected_cursor = match key_type {
0 | 2 => cursor_before.saturating_add(half_page as u32).min(max_cursor),
1 | 3 => cursor_before.saturating_sub(half_page as u32),
_ => cursor_before,
};
prop_assert_eq!(
app.results_cursor.cursor_line(), expected_cursor,
"Page scroll key {} should change cursor from {} to {} (half_page={}), got {}",
key_type, cursor_before, expected_cursor, half_page, app.results_cursor.cursor_line()
);
prop_assert!(app.search.is_confirmed(), "Search should remain confirmed after navigation");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_navigation_preserves_match_index(
viewport_height in 10u16..50,
max_offset in 20u16..200,
initial_cursor_factor in 0.0f64..1.0,
n_presses in 0usize..6,
key_type in 0u8..15,
) {
let mut app = test_app(r#"{"name": "test"}"#);
let content_lines = max_offset as u32 + viewport_height as u32;
let content: String = (0..content_lines)
.map(|i| format!("line {} test\n", i))
.collect();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().last_successful_result_unformatted = Some(Arc::new(content.clone()));
app.query.as_mut().unwrap().result = Ok(content.clone());
app.results_scroll.update_bounds(content_lines, viewport_height);
app.results_scroll.update_h_bounds(100, 40);
app.results_cursor.update_total_lines(content_lines);
let max_cursor = content_lines.saturating_sub(1);
let initial_cursor = ((initial_cursor_factor * max_cursor as f64) as u32).min(max_cursor);
app.results_cursor.move_to_line(initial_cursor);
app.results_scroll.h_offset = 10;
open_search(&mut app);
app.search.search_textarea_mut().insert_str("test");
app.search.update_matches(&content);
handle_search_key(&mut app, key(KeyCode::Enter));
prop_assert!(app.search.is_confirmed(), "Search should be confirmed");
prop_assert!(!app.search.matches().is_empty(), "Should have matches");
for _ in 0..n_presses {
handle_search_key(&mut app, key(KeyCode::Char('n')));
}
let match_index_before = app.search.current_index();
let test_key = match key_type {
0 => key(KeyCode::Char('j')),
1 => key(KeyCode::Char('k')),
2 => key(KeyCode::Char('J')),
3 => key(KeyCode::Char('K')),
4 => key(KeyCode::Char('g')),
5 => key(KeyCode::Char('G')),
6 => key(KeyCode::Char('h')),
7 => key(KeyCode::Char('l')),
8 => key(KeyCode::Char('H')),
9 => key(KeyCode::Char('L')),
10 => key(KeyCode::Char('0')),
11 => key(KeyCode::Char('^')),
12 => key_with_mods(KeyCode::Char('d'), KeyModifiers::CONTROL),
13 => key_with_mods(KeyCode::Char('u'), KeyModifiers::CONTROL),
14 => key(KeyCode::PageDown),
_ => key(KeyCode::Char('j')),
};
handle_search_key(&mut app, test_key);
prop_assert_eq!(
app.search.current_index(), match_index_before,
"Navigation key {} should not change match index (was {}, now {})",
key_type, match_index_before, app.search.current_index()
);
prop_assert!(app.search.is_confirmed(), "Search should remain confirmed after navigation");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_scroll_preserves_search_state(
num_matches in 1usize..20,
query in "[a-zA-Z]{1,10}",
viewport_height in 10u16..50,
max_offset in 20u16..200,
initial_offset in 0u16..100,
scroll_op in 0u8..8,
) {
use crate::search::search_state::Match;
let mut app = test_app(r#"{"name": "test"}"#);
app.search.open();
app.search.search_textarea_mut().insert_str(&query);
let content: String = (0..num_matches)
.map(|i| format!("line {} {}\n", i, query))
.collect();
app.query.as_mut().unwrap().last_successful_result = Some(Arc::new(content.clone()));
app.search.update_matches(&content);
let matches_before: Vec<Match> = app.search.matches().to_vec();
let current_index_before = app.search.current_index();
let query_before = app.search.query().to_string();
let visible_before = app.search.is_visible();
app.results_scroll.viewport_height = viewport_height;
app.results_scroll.max_offset = max_offset;
app.results_scroll.offset = initial_offset.min(max_offset);
match scroll_op {
0 => app.results_scroll.scroll_up(1),
1 => app.results_scroll.scroll_down(1),
2 => app.results_scroll.scroll_up(10),
3 => app.results_scroll.scroll_down(10),
4 => app.results_scroll.page_up(),
5 => app.results_scroll.page_down(),
6 => app.results_scroll.jump_to_top(),
7 => app.results_scroll.jump_to_bottom(),
_ => app.results_scroll.scroll_down(1),
}
prop_assert_eq!(
app.search.matches().to_vec(), matches_before,
"Matches should be unchanged after scroll"
);
prop_assert_eq!(
app.search.current_index(), current_index_before,
"Current index should be unchanged after scroll"
);
prop_assert_eq!(
app.search.query(), query_before,
"Query should be unchanged after scroll"
);
prop_assert_eq!(
app.search.is_visible(), visible_before,
"Visibility should be unchanged after scroll"
);
}
}