use crate::app::App;
use crate::config::Config;
use crate::input::FileLoader;
use proptest::prelude::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::path::PathBuf;
fn create_test_terminal(width: u16, height: u16) -> Terminal<TestBackend> {
let backend = TestBackend::new(width, height);
Terminal::new(backend).unwrap()
}
fn render_to_string(app: &mut App, width: u16, height: u16) -> String {
let mut terminal = create_test_terminal(width, height);
terminal.draw(|f| app.render(f)).unwrap();
terminal.backend().to_string()
}
fn create_app_with_loading_loader() -> App {
let loader = FileLoader::spawn_load(PathBuf::from("/tmp/test_loading_file.json"));
App::new_with_loader(loader, &Config::default())
}
#[cfg(test)]
mod property_tests {
use super::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_loading_state_shows_indicator(
width in 40u16..120u16,
height in 10u16..40u16,
) {
let mut app = create_app_with_loading_loader();
prop_assert!(app.query.is_none(), "Query should be None when loading");
prop_assert!(app.file_loader.is_some(), "FileLoader should be present");
if let Some(loader) = &app.file_loader {
prop_assert!(loader.is_loading(), "FileLoader should be in Loading state");
}
let output = render_to_string(&mut app, width, height);
prop_assert!(
output.contains("Loading file..."),
"Rendered output should contain 'Loading file...' when file_loader is Loading. Output:\n{}",
output
);
prop_assert!(
output.contains("Loading"),
"Rendered output should contain 'Loading' title"
);
}
}
}
#[cfg(test)]
mod spinner_tests {
use super::super::{SPINNER_CHARS, get_spinner};
use crate::theme;
#[test]
fn test_spinner_first_frame() {
let (char, color) = get_spinner(0);
assert_eq!(char, SPINNER_CHARS[0]);
assert_eq!(color, theme::results::SPINNER_COLORS[0]);
}
#[test]
fn test_spinner_second_frame() {
let (char, color) = get_spinner(8);
assert_eq!(char, SPINNER_CHARS[1]);
assert_eq!(color, theme::results::SPINNER_COLORS[1]);
}
#[test]
fn test_spinner_char_cycling() {
for i in 0..10 {
let (char, _) = get_spinner(i * 8);
assert_eq!(
char,
SPINNER_CHARS[i as usize],
"Frame {} should have char {}",
i * 8,
SPINNER_CHARS[i as usize]
);
}
}
#[test]
fn test_spinner_color_cycling() {
for i in 0..8 {
let (_, color) = get_spinner(i * 8);
assert_eq!(
color,
theme::results::SPINNER_COLORS[i as usize],
"Frame {} should have color at index {}",
i * 8,
i
);
}
}
#[test]
fn test_spinner_char_wrapping() {
let (char_start, _) = get_spinner(0);
let (char_wrap, _) = get_spinner(80);
assert_eq!(
char_start, char_wrap,
"Character should wrap after 10 iterations"
);
}
#[test]
fn test_spinner_color_wrapping() {
let (_, color_start) = get_spinner(0);
let (_, color_wrap) = get_spinner(64);
assert_eq!(
color_start, color_wrap,
"Color should wrap after 8 iterations"
);
}
#[test]
fn test_spinner_independent_cycling() {
let (char, _) = get_spinner(40);
assert_eq!(char, SPINNER_CHARS[5]);
let (char, _) = get_spinner(48);
assert_eq!(char, SPINNER_CHARS[6]);
let (char, color) = get_spinner(64);
assert_eq!(char, SPINNER_CHARS[8]);
assert_eq!(color, theme::results::SPINNER_COLORS[0]);
}
#[test]
fn test_spinner_large_frame_count() {
let (char, color) = get_spinner(u64::MAX);
assert!(SPINNER_CHARS.contains(&char));
assert!(theme::results::SPINNER_COLORS.contains(&color));
}
#[test]
fn test_spinner_animation_speed() {
let (char0, _) = get_spinner(0);
for frame in 1..8 {
let (char, _) = get_spinner(frame);
assert_eq!(char, char0, "Frames 0-7 should all use same character");
}
let (char8, _) = get_spinner(8);
assert_ne!(
char8, char0,
"Frame 8 should use different character than frame 0"
);
}
}
#[cfg(test)]
mod position_indicator_tests {
use super::super::format_position_indicator;
use crate::scroll::ScrollState;
fn create_scroll_state(offset: u16, viewport_height: u16, max_offset: u16) -> ScrollState {
ScrollState {
offset,
max_offset,
viewport_height,
h_offset: 0,
max_h_offset: 0,
viewport_width: 80,
}
}
#[test]
fn test_empty_content_returns_empty_string() {
let scroll = create_scroll_state(0, 20, 0);
assert_eq!(format_position_indicator(&scroll, 0), "");
}
#[test]
fn test_single_line() {
let scroll = create_scroll_state(0, 20, 0);
assert_eq!(format_position_indicator(&scroll, 1), "L1-1/1 (0%)");
}
#[test]
fn test_at_top() {
let scroll = create_scroll_state(0, 20, 80);
assert_eq!(format_position_indicator(&scroll, 100), "L1-20/100 (0%)");
}
#[test]
fn test_at_bottom() {
let scroll = create_scroll_state(80, 20, 80);
assert_eq!(format_position_indicator(&scroll, 100), "L81-100/100 (80%)");
}
#[test]
fn test_middle_position() {
let scroll = create_scroll_state(45, 20, 80);
assert_eq!(format_position_indicator(&scroll, 100), "L46-65/100 (45%)");
}
#[test]
fn test_viewport_larger_than_content() {
let scroll = create_scroll_state(0, 50, 0);
assert_eq!(format_position_indicator(&scroll, 10), "L1-10/10 (0%)");
}
#[test]
fn test_small_file_exact_viewport() {
let scroll = create_scroll_state(0, 20, 0);
assert_eq!(format_position_indicator(&scroll, 20), "L1-20/20 (0%)");
}
#[test]
fn test_large_file() {
let scroll = create_scroll_state(500, 50, 950);
assert_eq!(
format_position_indicator(&scroll, 1000),
"L501-550/1000 (50%)"
);
}
#[test]
fn test_percentage_rounding() {
let scroll = create_scroll_state(33, 20, 80);
assert_eq!(format_position_indicator(&scroll, 100), "L34-53/100 (33%)");
}
#[test]
fn test_near_end_clamping() {
let scroll = create_scroll_state(95, 20, 80);
assert_eq!(format_position_indicator(&scroll, 100), "L96-100/100 (95%)");
}
}
#[cfg(test)]
mod scrollbar_tests {
use super::super::render_scrollbar;
use crate::scroll::ScrollState;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Borders};
fn create_scroll_state(offset: u16, viewport_height: u16, max_offset: u16) -> ScrollState {
ScrollState {
offset,
max_offset,
viewport_height,
h_offset: 0,
max_h_offset: 0,
viewport_width: 80,
}
}
fn render_scrollbar_to_string(
scroll: &ScrollState,
line_count: u32,
width: u16,
height: u16,
) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, width, height);
render_scrollbar(frame, area, scroll, line_count);
})
.unwrap();
terminal.backend().to_string()
}
fn render_scrollbar_with_border_to_string(
scroll: &ScrollState,
line_count: u32,
width: u16,
height: u16,
) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, width, height);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default());
frame.render_widget(block, area);
render_scrollbar(frame, area, scroll, line_count);
})
.unwrap();
terminal.backend().to_string()
}
#[test]
fn test_scrollbar_hidden_when_content_fits() {
let scroll = create_scroll_state(0, 20, 0);
let output = render_scrollbar_to_string(&scroll, 10, 80, 22);
assert!(
!output.contains('█') && !output.contains('│') && !output.contains('▐'),
"Scrollbar should not render when content fits viewport"
);
}
#[test]
fn test_scrollbar_visible_when_content_exceeds_viewport() {
let scroll = create_scroll_state(0, 20, 80);
let output = render_scrollbar_to_string(&scroll, 100, 80, 22);
assert!(
output.contains('█'),
"Scrollbar thumb should render when content exceeds viewport. Output:\n{}",
output
);
}
#[test]
fn test_scrollbar_position_at_top() {
let scroll = create_scroll_state(0, 20, 80);
let backend = TestBackend::new(80, 22);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 80, 22);
render_scrollbar(frame, area, &scroll, 100);
})
.unwrap();
let buffer = terminal.backend().buffer();
let col = 79;
let mut thumb_positions: Vec<u16> = Vec::new();
for row in 0..22 {
if buffer[(col, row)].symbol() == "█" {
thumb_positions.push(row);
}
}
assert!(
!thumb_positions.is_empty(),
"Scrollbar thumb should be visible"
);
let avg_position: f32 =
thumb_positions.iter().map(|&r| r as f32).sum::<f32>() / thumb_positions.len() as f32;
assert!(
avg_position < 11.0,
"Scrollbar thumb should be near top when offset=0. Avg position: {}",
avg_position
);
}
#[test]
fn test_scrollbar_position_at_bottom() {
let scroll = create_scroll_state(80, 20, 80);
let backend = TestBackend::new(80, 22);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 80, 22);
render_scrollbar(frame, area, &scroll, 100);
})
.unwrap();
let buffer = terminal.backend().buffer();
let col = 79;
let mut thumb_positions: Vec<u16> = Vec::new();
for row in 0..22 {
if buffer[(col, row)].symbol() == "█" {
thumb_positions.push(row);
}
}
assert!(
!thumb_positions.is_empty(),
"Scrollbar thumb should be visible"
);
let avg_position: f32 =
thumb_positions.iter().map(|&r| r as f32).sum::<f32>() / thumb_positions.len() as f32;
assert!(
avg_position > 11.0,
"Scrollbar thumb should be near bottom when offset=max. Avg position: {}",
avg_position
);
}
#[test]
fn test_scrollbar_position_middle() {
let scroll = create_scroll_state(40, 20, 80);
let backend = TestBackend::new(80, 22);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 80, 22);
render_scrollbar(frame, area, &scroll, 100);
})
.unwrap();
let buffer = terminal.backend().buffer();
let col = 79;
let mut thumb_positions: Vec<u16> = Vec::new();
for row in 0..22 {
if buffer[(col, row)].symbol() == "█" {
thumb_positions.push(row);
}
}
assert!(
!thumb_positions.is_empty(),
"Scrollbar thumb should be visible"
);
let avg_position: f32 =
thumb_positions.iter().map(|&r| r as f32).sum::<f32>() / thumb_positions.len() as f32;
assert!(
avg_position > 5.0 && avg_position < 17.0,
"Scrollbar thumb should be near middle when offset=40. Avg position: {}",
avg_position
);
}
#[test]
fn snapshot_scrollbar_with_border_at_top() {
let scroll = create_scroll_state(0, 10, 90);
let output = render_scrollbar_with_border_to_string(&scroll, 100, 20, 12);
assert_snapshot!(output);
}
#[test]
fn snapshot_scrollbar_with_border_at_middle() {
let scroll = create_scroll_state(45, 10, 90);
let output = render_scrollbar_with_border_to_string(&scroll, 100, 20, 12);
assert_snapshot!(output);
}
#[test]
fn snapshot_scrollbar_with_border_at_bottom() {
let scroll = create_scroll_state(90, 10, 90);
let output = render_scrollbar_with_border_to_string(&scroll, 100, 20, 12);
assert_snapshot!(output);
}
#[test]
fn test_scrollbar_respects_border_corners() {
let scroll = create_scroll_state(0, 10, 90);
let backend = TestBackend::new(20, 12);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 20, 12);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default());
frame.render_widget(block, area);
render_scrollbar(frame, area, &scroll, 100);
})
.unwrap();
let buffer = terminal.backend().buffer();
let col = 19;
let top_row_symbol = buffer[(col, 0)].symbol();
let bottom_row_symbol = buffer[(col, 11)].symbol();
assert_eq!(
top_row_symbol, "┐",
"Top-right corner should be preserved (got: {})",
top_row_symbol
);
assert_eq!(
bottom_row_symbol, "┘",
"Bottom-right corner should be preserved (got: {})",
bottom_row_symbol
);
let mut has_scrollbar_in_middle = false;
for row in 1..11 {
let symbol = buffer[(col, row)].symbol();
if symbol == "█" || symbol == "│" {
has_scrollbar_in_middle = true;
break;
}
}
assert!(
has_scrollbar_in_middle,
"Scrollbar should render between the corners"
);
}
}