use std::path::Path; use std::time::Instant; use log_viewer_core::config::ColorConfig; use log_viewer_core::io::progressive_reader::{ IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height, spawn_indexer, }; use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars, MAX_WRAP_INPUT_LEN}; use log_viewer_core::types::LogLevel; use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher}; use unicode_width::UnicodeWidthChar; use crate::color::AVAILABLE_COLORS; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum AppMode { Normal, Settings, } pub(crate) enum AppLoadingState { Empty, Loading { reader: ProgressiveFileReader, estimated_lines: u64, progress_percent: f64, }, Ready { reader: ProgressiveFileReader, }, Error(String), } // ── Viewport cache (on-demand, viewport-sized) ─────────────────── pub(crate) struct ViewportEntry { pub(crate) wrapped_rows: Vec, pub(crate) level: Option, pub(crate) visual_height: usize, } pub(crate) struct ViewportCache { pub(crate) entries: Vec, pub(crate) logical_start: usize, pub(crate) width: usize, json_format: bool, cached_total_visual_rows: Option, } impl ViewportCache { pub(crate) fn new() -> Self { Self { entries: Vec::new(), logical_start: 0, width: 0, json_format: false, cached_total_visual_rows: None, } } pub(crate) fn invalidate(&mut self) { self.entries.clear(); self.logical_start = 0; self.width = 0; self.cached_total_visual_rows = None; } pub(crate) fn needs_recompute(&self, width: usize, json_format: bool) -> bool { self.width != width || self.json_format != json_format } pub(crate) fn get_entry(&self, logical_line: usize) -> Option<&ViewportEntry> { if logical_line >= self.logical_start { let idx = logical_line - self.logical_start; self.entries.get(idx) } else { None } } } // ── App ────────────────────────────────────────────────────────── pub struct App { pub should_quit: bool, // File state loading_state: AppLoadingState, pub(crate) file_path: Option, // Scroll state pub(crate) cursor_line: usize, pub(crate) v_offset: usize, pub(crate) v_sub_offset: usize, // Viewport cache (on-demand, viewport-sized) pub(crate) viewport_cache: ViewportCache, // Viewport #[allow(dead_code)] pub(crate) content_width: u16, pub(crate) content_height: u16, // gg state machine pub(crate) last_g_press: Option, // JSON formatting pub(crate) json_format: bool, // Mode & coloring pub(crate) mode: AppMode, pub(crate) color_config: ColorConfig, // Settings panel state pub(crate) settings_cursor: usize, pub(crate) settings_draft: ColorConfig, pub(crate) settings_error: Option, // File watcher file_watcher: Option, /// Set to true when file-change events arrive during Loading state. /// After Loading→Ready transition, a full reload is triggered once. reload_after_loading: bool, } impl App { pub fn new() -> Self { Self { should_quit: false, loading_state: AppLoadingState::Empty, file_path: None, cursor_line: 0, v_offset: 0, v_sub_offset: 0, viewport_cache: ViewportCache::new(), content_width: 0, content_height: 0, last_g_press: None, json_format: false, mode: AppMode::Normal, color_config: ColorConfig::default(), settings_cursor: 0, settings_draft: ColorConfig::default(), settings_error: None, file_watcher: None, reload_after_loading: false, } } pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> { // Cancel any existing background indexer by dropping the old state self.file_watcher = None; let mut pfr = ProgressiveFileReader::open(Path::new(path)) .map_err(|e| anyhow::anyhow!("{e}"))?; if pfr.is_sampling() { // Cache miss: spawn background indexer let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1); let generation = pfr.generation(); let indexer_rx = spawn_indexer( pfr.path().to_path_buf(), generation, 80, false, cancel_rx, ); pfr = ProgressiveFileReader::with_channels( Path::new(path), cancel_tx, indexer_rx, generation, ).map_err(|e| anyhow::anyhow!("{e}"))?; let estimated = pfr.line_count() as u64; self.loading_state = AppLoadingState::Loading { reader: pfr, estimated_lines: estimated, progress_percent: 0.0, }; } else { // Cache hit: Ready state self.loading_state = AppLoadingState::Ready { reader: pfr }; } self.file_watcher = FileWatcher::watch(Path::new(path)).ok(); self.file_path = Some(path.to_string()); self.cursor_line = 0; self.v_offset = 0; self.v_sub_offset = 0; self.viewport_cache.invalidate(); self.last_g_press = None; self.json_format = false; self.mode = AppMode::Normal; self.reload_after_loading = false; Ok(()) } // ── On-demand viewport computation ─────────────────────────── /// Compute a single line's viewport entry (wrapped rows + level + height). fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry { let raw = self.get_line(line).unwrap_or_default(); // Guard 1: oversized raw input — skip detect_level and JSON formatting // to avoid O(n) parsing overhead on huge lines. if raw.len() > MAX_WRAP_INPUT_LEN { return ViewportEntry { wrapped_rows: vec![truncate_to_columns(&raw, width)], level: None, visual_height: 1, }; } let level = log_viewer_core::parser::level::detect_level(&raw); let display_text = if self.json_format { format_json_line(&raw) } else { raw }; // Guard 2: JSON pretty-printing may expand a line beyond the limit. if display_text.len() > MAX_WRAP_INPUT_LEN { return ViewportEntry { wrapped_rows: vec![truncate_to_columns(&display_text, width)], level, visual_height: 1, }; } let mut wrapped = Vec::new(); for sub_line in display_text.split('\n') { wrapped.extend(wrap_line_chars(sub_line, width)); } let visual_height = wrapped.len().max(1); ViewportEntry { wrapped_rows: wrapped, level, visual_height, } } /// Compute visual height for a single line without storing it. fn compute_visual_height(&self, line: usize, width: usize) -> usize { let raw = self.get_line(line).unwrap_or_default(); // Guard 1: oversized raw input. if raw.len() > MAX_WRAP_INPUT_LEN { return 1; } let display_text = if self.json_format { format_json_line(&raw) } else { raw }; // Guard 2: post-format expansion. if display_text.len() > MAX_WRAP_INPUT_LEN { return 1; } let mut height = 0; for sub_line in display_text.split('\n') { height += wrap_line_chars(sub_line, width).len(); } height.max(1) } /// Find (logical_line, offset_in_line) for a given visual row offset. fn find_logical_line_at_visual_row(&self, visual_row: usize, _width: usize) -> (usize, usize) { if let Some(index) = self.get_visual_height_index() { return index.visual_row_to_logical_row_with_offset(visual_row as u64); } (visual_row.min(self.total_lines().saturating_sub(1)), 0) } /// Ensure the viewport cache covers the visible range. /// Returns (start_logical, offset_in_line) for rendering. pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) { let viewport_height = self.content_height as usize; if !self.is_loaded() || width == 0 || viewport_height == 0 { return (0, 0); } let params_changed = self.viewport_cache.needs_recompute(width, self.json_format); if params_changed { self.viewport_cache.invalidate(); self.viewport_cache.width = width; self.viewport_cache.json_format = self.json_format; self.ensure_visual_height_index(width); let cursor_first = self.cursor_to_first_visual_row(self.cursor_line); let half_height = (self.content_height as usize) / 2; self.v_offset = cursor_first.saturating_sub(half_height); self.clamp_v_offset(); } // Find start logical line from v_offset let (start_logical, offset_in_line) = if self.is_loading() { (self.v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset) } else { self.find_logical_line_at_visual_row(self.v_offset, width) }; // Compute viewport entries self.viewport_cache.entries.clear(); self.viewport_cache.logical_start = start_logical; let total = self.total_lines(); let mut rows_remaining = viewport_height + offset_in_line; for line_idx in start_logical..total { if rows_remaining == 0 { break; } let entry = self.compute_line_entry(line_idx, width); rows_remaining = rows_remaining.saturating_sub(entry.visual_height); self.viewport_cache.entries.push(entry); } // Post-check: ensure cursor_line is within rendered entries (Loading + JSON expansion) if self.is_loading() { let entries = &self.viewport_cache.entries; let last_entry_end = self.viewport_cache.logical_start + entries.len() + entries.last().map(|e| e.visual_height).unwrap_or(0).saturating_sub(1); let first_entry_start = self.viewport_cache.logical_start; if self.cursor_line >= last_entry_end || self.cursor_line < first_entry_start { self.v_offset = self.cursor_line; self.viewport_cache.entries.clear(); self.viewport_cache.logical_start = self.cursor_line; self.fill_viewport_entries(self.cursor_line, width, viewport_height); } } (start_logical, offset_in_line) } fn fill_viewport_entries(&mut self, start_logical: usize, width: usize, viewport_height: usize) { let total = self.total_lines(); let mut rows_remaining = viewport_height; for line_idx in start_logical..total { if rows_remaining == 0 { break; } let entry = self.compute_line_entry(line_idx, width); rows_remaining = rows_remaining.saturating_sub(entry.visual_height); self.viewport_cache.entries.push(entry); } } /// Compute total visual rows (cached, lazily evaluated). /// Returns `total_lines` for sampling mode (1:1 mapping). fn total_visual_rows(&mut self) -> usize { if self.is_loading() { return self.total_lines(); } if let Some(index) = self.get_visual_height_index() { return index.total_visual_rows() as usize; } self.total_lines() } // ── Scroll methods ────────────────────────────────────────────── pub fn scroll_down_line(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } // VHI present → visual-row scroll if self.get_visual_height_index().is_some() { let max_offset = self .total_visual_rows() .saturating_sub(self.content_height as usize); if self.v_offset < max_offset { self.v_offset = self.v_offset.saturating_add(1); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } else { let last = self.total_lines() - 1; if self.cursor_line < last { self.cursor_line += 1; } } } else { // Loading/no-index: visual-row scroll via v_sub_offset let width = self.get_content_width(); if width > 0 && self.total_lines() > 0 { let current_height = self.compute_visual_height(self.v_offset, width); if self.v_sub_offset + 1 < current_height { self.v_sub_offset += 1; } else { let last = self.total_lines() - 1; if self.v_offset < last { self.v_offset += 1; self.v_sub_offset = 0; } } self.cursor_line = self.v_offset; } } } pub fn scroll_up_line(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } // VHI present → visual-row scroll if self.get_visual_height_index().is_some() { if self.v_offset > 0 { self.v_offset = self.v_offset.saturating_sub(1); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } else { self.cursor_line = self.cursor_line.saturating_sub(1); } } else { // Loading/no-index: visual-row scroll via v_sub_offset if self.v_sub_offset > 0 { self.v_sub_offset -= 1; } else if self.v_offset > 0 { self.v_offset -= 1; let width = self.get_content_width(); self.v_sub_offset = if width > 0 { self.compute_visual_height(self.v_offset, width).saturating_sub(1) } else { 0 }; } self.cursor_line = self.v_offset; } } pub fn scroll_down_half_page(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } let half = self.content_height as usize / 2; self.v_offset = self.v_offset.saturating_add(half); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } pub fn scroll_up_half_page(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } let half = self.content_height as usize / 2; self.v_offset = self.v_offset.saturating_sub(half); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } pub fn scroll_down_page(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } let page = self.content_height as usize; self.v_offset = self.v_offset.saturating_add(page); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } pub fn scroll_up_page(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } let page = self.content_height as usize; self.v_offset = self.v_offset.saturating_sub(page); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } pub fn scroll_to_top(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } self.cursor_line = 0; self.v_offset = 0; self.v_sub_offset = 0; } pub fn scroll_to_bottom(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } self.cursor_line = self.total_lines().saturating_sub(1); self.v_sub_offset = 0; self.ensure_cursor_visible(); self.clamp_v_offset(); } // ── Internal helpers ──────────────────────────────────────────── fn ensure_cursor_visible(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } if self.is_loading() { return; } let cursor_first = self.cursor_to_first_visual_row(self.cursor_line); let height = if let Some(index) = self.get_visual_height_index() { index.visual_height_of_line(self.cursor_line) } else { 1 }; let cursor_last = cursor_first + height.saturating_sub(1); let content_h = self.content_height as usize; if cursor_first < self.v_offset { self.v_offset = cursor_first; } else if cursor_last >= self.v_offset.saturating_add(content_h) { self.v_offset = cursor_last.saturating_sub(content_h).saturating_add(1); } self.clamp_v_offset(); } fn clamp_v_offset(&mut self) { let max_offset = self .total_visual_rows() .saturating_sub(self.content_height as usize); self.v_offset = self.v_offset.min(max_offset); } pub(crate) fn cursor_to_first_visual_row(&self, line: usize) -> usize { if self.is_loading() { return line; } if let Some(index) = self.get_visual_height_index() { return index.cursor_to_first_visual_row(line) as usize; } line } pub(crate) fn visual_row_to_logical_row(&self, visual_row: usize) -> usize { if self.is_loading() { return visual_row.min(self.total_lines().saturating_sub(1)); } if let Some(index) = self.get_visual_height_index() { return index.visual_row_to_logical_row(visual_row as u64); } visual_row.min(self.total_lines().saturating_sub(1)) } // ── Key handling ──────────────────────────────────────────────── pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::KeyEventKind; let should_handle = match key.kind { KeyEventKind::Press => true, KeyEventKind::Repeat => self.is_repeatable_key(&key), KeyEventKind::Release => false, }; if !should_handle { return; } match self.mode { AppMode::Normal => self.handle_normal_key(key), AppMode::Settings => self.handle_settings_key(key), } } /// Keys that should auto-repeat when held (scroll/navigation only). fn is_repeatable_key(&self, key: &crossterm::event::KeyEvent) -> bool { use crossterm::event::{KeyCode, KeyModifiers}; let plain = key.modifiers.is_empty(); let ctrl = key.modifiers == KeyModifiers::CONTROL; match self.mode { AppMode::Normal => { (plain && matches!( key.code, KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('k') | KeyCode::Up | KeyCode::PageDown | KeyCode::PageUp )) || (ctrl && matches!( key.code, KeyCode::Char('d') | KeyCode::Char('u') | KeyCode::Char('f') | KeyCode::Char('b') )) } AppMode::Settings => plain && matches!( key.code, KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('k') | KeyCode::Up | KeyCode::Left | KeyCode::Right ), } } fn handle_normal_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::{KeyCode, KeyModifiers}; match key.code { KeyCode::Char('q') | KeyCode::Esc => { self.should_quit = true; self.last_g_press = None; } KeyCode::Char('j') | KeyCode::Down => { self.scroll_down_line(); self.last_g_press = None; } KeyCode::Char('k') | KeyCode::Up => { self.scroll_up_line(); self.last_g_press = None; } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll_down_half_page(); self.last_g_press = None; } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll_up_half_page(); self.last_g_press = None; } KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll_down_page(); self.last_g_press = None; } KeyCode::PageDown => { self.scroll_down_page(); self.last_g_press = None; } KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll_up_page(); self.last_g_press = None; } KeyCode::PageUp => { self.scroll_up_page(); self.last_g_press = None; } KeyCode::Char('G') | KeyCode::End => { self.scroll_to_bottom(); self.last_g_press = None; } KeyCode::Char('g') => { if let Some(instant) = self.last_g_press && instant.elapsed().as_millis() < 500 { self.scroll_to_top(); self.last_g_press = None; return; } self.last_g_press = Some(Instant::now()); } KeyCode::Home => { self.scroll_to_top(); self.last_g_press = None; } KeyCode::Tab => { self.json_format = !self.json_format; self.viewport_cache.invalidate(); let width = self.viewport_cache.width; let (new_offset, new_sub) = self.rebase_offset_for_invalidate(); if let AppLoadingState::Ready { reader } = &mut self.loading_state { reader.invalidate_visual_height_index(); if width > 0 { reader.start_visual_height_rebuild(width, self.json_format); } } self.v_offset = new_offset; self.v_sub_offset = new_sub; self.last_g_press = None; } KeyCode::Char('s') | KeyCode::Char('S') if !key.modifiers.contains(KeyModifiers::CONTROL) => { self.settings_draft = self.color_config.clone(); self.settings_error = None; self.mode = AppMode::Settings; } _ => { self.last_g_press = None; } } } fn handle_settings_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::KeyCode; match key.code { KeyCode::Esc | KeyCode::Char('q') => { self.settings_error = None; self.mode = AppMode::Normal; } KeyCode::Enter => { let draft = self.settings_draft.clone(); match draft.save() { Ok(()) => { self.color_config = draft; self.settings_error = None; self.mode = AppMode::Normal; } Err(e) => { self.settings_error = Some(format!("Failed to save settings: {e}")); } } } KeyCode::Char('j') | KeyCode::Down => { if self.settings_cursor < 5 { self.settings_cursor += 1; } } KeyCode::Char('k') | KeyCode::Up => { self.settings_cursor = self.settings_cursor.saturating_sub(1); } KeyCode::Left => { self.cycle_color(self.settings_cursor, false); } KeyCode::Right => { self.cycle_color(self.settings_cursor, true); } KeyCode::Char(c) if ('1'..='8').contains(&c) => { let idx = (c as usize) - ('1' as usize); self.set_color(self.settings_cursor, AVAILABLE_COLORS[idx]); } _ => {} } } fn cycle_color(&mut self, level_idx: usize, forward: bool) { let current = self.get_settings_color(level_idx).to_string(); let colors = AVAILABLE_COLORS; let pos = colors.iter().position(|&c| c == current); let new_pos = match pos { Some(p) => { if forward { (p + 1) % colors.len() } else { if p == 0 { colors.len() - 1 } else { p - 1 } } } None => { if forward { 0 } else { colors.len() - 1 } } }; self.set_color(level_idx, colors[new_pos]); } fn get_settings_color(&self, level_idx: usize) -> &str { match level_idx { 0 => &self.settings_draft.error, 1 => &self.settings_draft.warn, 2 => &self.settings_draft.info, 3 => &self.settings_draft.debug, 4 => &self.settings_draft.trace, 5 => &self.settings_draft.unknown, _ => "white", } } fn set_color(&mut self, level_idx: usize, color_name: &str) { self.settings_error = None; match level_idx { 0 => self.settings_draft.error = color_name.to_string(), 1 => self.settings_draft.warn = color_name.to_string(), 2 => self.settings_draft.info = color_name.to_string(), 3 => self.settings_draft.debug = color_name.to_string(), 4 => self.settings_draft.trace = color_name.to_string(), 5 => self.settings_draft.unknown = color_name.to_string(), _ => {} } } // ── Utility methods ───────────────────────────────────────────── #[allow(dead_code)] pub fn get_line(&self, idx: usize) -> Option { match &self.loading_state { AppLoadingState::Ready { reader } => reader.get_line(idx), AppLoadingState::Loading { reader, .. } => reader.get_line(idx), _ => None, } } #[allow(dead_code)] pub fn file_name(&self) -> Option<&str> { self.file_path .as_ref() .and_then(|p| std::path::Path::new(p).file_name().and_then(|n| n.to_str())) } pub fn total_lines(&self) -> usize { match &self.loading_state { AppLoadingState::Ready { reader } => reader.line_count(), AppLoadingState::Loading { reader, estimated_lines, .. } => { // Use estimated total lines (not sampled_line_count) so the user can // scroll freely during indexing. get_line() incrementally scans // forward on demand, so lines beyond the initial 64KB are still // accessible. The .max() guards against under-estimates. (*estimated_lines as usize).max(reader.sampled_line_count()) } _ => 0, } } pub fn is_loaded(&self) -> bool { matches!( self.loading_state, AppLoadingState::Ready { .. } | AppLoadingState::Loading { .. } ) } pub fn is_loading(&self) -> bool { matches!(self.loading_state, AppLoadingState::Loading { .. }) } pub fn is_error(&self) -> bool { matches!(self.loading_state, AppLoadingState::Error(_)) } fn get_visual_height_index(&self) -> Option<&VisualHeightIndex> { match &self.loading_state { AppLoadingState::Ready { reader } => match &reader.state { log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index, .. } => visual_height_index.as_ref(), _ => None, }, _ => None, } } /// Compute the rebased offset pair (logical_line, sub_row) from the /// current visual-row `v_offset`. Returns `(v_offset, v_sub_offset)` /// suitable for the no-VHI fallback scrolling path. /// /// MUST be called before borrowing `&mut self.loading_state` for /// `invalidate_visual_height_index`, because it reads VHI through /// `&self`. fn rebase_offset_for_invalidate(&self) -> (usize, usize) { if self.get_visual_height_index().is_some() { let top_visual = self.v_offset; let top_line = self.visual_row_to_logical_row(top_visual); let line_first_visual = self.cursor_to_first_visual_row(top_line); let sub = top_visual.saturating_sub(line_first_visual); (top_line, sub) } else { (self.v_offset, self.v_sub_offset) } } fn ensure_visual_height_index(&mut self, width: usize) { let needs_rebuild = match self.get_visual_height_index() { Some(idx) => !idx.is_valid_for(self.json_format, width), None => true, }; if needs_rebuild { let (new_offset, new_sub) = self.rebase_offset_for_invalidate(); if let AppLoadingState::Ready { reader } = &mut self.loading_state { reader.invalidate_visual_height_index(); reader.start_visual_height_rebuild(width, self.json_format); } self.v_offset = new_offset; self.v_sub_offset = new_sub; } } pub fn error_message(&self) -> Option<&str> { match &self.loading_state { AppLoadingState::Error(msg) => Some(msg), _ => None, } } pub fn loading_progress(&self) -> Option { match &self.loading_state { AppLoadingState::Loading { progress_percent, .. } => Some(*progress_percent), _ => None, } } pub fn estimated_lines(&self) -> Option { match &self.loading_state { AppLoadingState::Loading { estimated_lines, .. } => Some(*estimated_lines), _ => None, } } #[cfg(test)] pub fn set_error_state(&mut self, msg: impl Into) { self.loading_state = AppLoadingState::Error(msg.into()); } #[allow(dead_code)] pub fn file_size(&self) -> u64 { match &self.loading_state { AppLoadingState::Ready { reader } => { reader.reader().map_or(0, |r| r.file_size()) } AppLoadingState::Loading { reader, .. } => { reader.reader().map_or(0, |r| r.file_size()) } _ => 0, } } fn get_content_width(&self) -> usize { if self.content_width > 0 { self.content_width as usize } else { 80 } } pub fn poll_file_watcher(&mut self) { let events: Vec = match &mut self.file_watcher { Some(w) => std::iter::from_fn(|| w.try_recv()).collect(), None => return, }; for event in events { match event { FileEvent::Appended { new_size: _ } => { self.handle_file_appended(); } FileEvent::Truncated { new_size: _ } => { self.handle_file_truncated(); } FileEvent::Rotated { new_inode: _ } => { // Don't auto-switch; old content preserved. // User can reload manually if desired. } FileEvent::Removed => { self.loading_state = AppLoadingState::Error("File has been deleted".into()); } FileEvent::WatcherError { message: _ } => {} } } } fn handle_file_appended(&mut self) { let width = self.get_content_width(); let rebased = self.rebase_offset_for_invalidate(); match &mut self.loading_state { AppLoadingState::Ready { reader } => { let old_reader_line_count = reader.line_count(); let status = reader.update_for_append(); match status { Ok( log_viewer_core::io::file_reader::AppendStatus::Appended(_new_lines), ) => { let _ = reader.save_cache(); let (old_line_count, can_extend) = { match &reader.state { log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index: Some(idx), .. } => (idx.line_count(), idx.is_valid_for(self.json_format, width)), _ => (0, false), } }; let new_line_count = reader.line_count(); if can_extend && old_line_count == old_reader_line_count { if let log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index: Some(index), reader: fr, } = &mut reader.state { if old_line_count > 0 { let last_old_line_text = fr.get_line(old_line_count - 1).unwrap_or(""); let new_h = compute_line_visual_height( last_old_line_text, width, self.json_format, ); index.replace_last_line_height(new_h); } let mut new_heights = Vec::with_capacity(new_line_count.saturating_sub(old_line_count)); for i in old_line_count..new_line_count { let line_text = fr.get_line(i).unwrap_or(""); new_heights.push(compute_line_visual_height( line_text, width, self.json_format, )); } index.extend_from_heights(&new_heights); } } else { let (new_offset, new_sub) = rebased; self.v_offset = new_offset; self.v_sub_offset = new_sub; reader.invalidate_visual_height_index(); reader.start_visual_height_rebuild(width, self.json_format); } self.viewport_cache.invalidate(); } Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => { let _ = reader.save_cache(); let (new_offset, _new_sub) = rebased; reader.invalidate_visual_height_index(); reader.start_visual_height_rebuild(width, self.json_format); self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1)); self.v_offset = new_offset; self.v_sub_offset = 0; self.viewport_cache.invalidate(); self.clamp_v_offset(); } Ok(log_viewer_core::io::file_reader::AppendStatus::Unchanged) | Err(_) => {} } } AppLoadingState::Loading { .. } => { self.reload_after_loading = true; } _ => {} } } fn reload_ready_reader(&mut self) { let width = self.get_content_width(); let (new_offset, _new_sub) = self.rebase_offset_for_invalidate(); if let AppLoadingState::Ready { reader } = &mut self.loading_state { let _ = reader.reload(); let _ = reader.save_cache(); reader.invalidate_visual_height_index(); reader.start_visual_height_rebuild(width, self.json_format); self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1)); self.v_offset = new_offset; self.v_sub_offset = 0; self.viewport_cache.invalidate(); self.clamp_v_offset(); } } fn handle_file_truncated(&mut self) { match &mut self.loading_state { AppLoadingState::Ready { .. } => { self.reload_ready_reader(); } AppLoadingState::Loading { .. } => { self.reload_after_loading = true; } _ => {} } } /// Poll the background indexer for progress/completion. /// Transitions Loading → Ready when indexing completes. /// Must be called every frame in the event loop. pub fn poll_background_indexer(&mut self) { // Poll visual height rebuild (Ready state only) if let AppLoadingState::Ready { reader } = &mut self.loading_state { if let Some(index) = reader.poll_visual_height_rebuild() { if let log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index, .. } = &mut reader.state { *visual_height_index = Some(index); } } } // Recalibrate v_offset from logical → visual-row now that VHI is available. // Must be outside the `reader` borrow above. if self.get_visual_height_index().is_some() { let logical_top = self.v_offset.min(self.total_lines().saturating_sub(1)); let sub = self.v_sub_offset; let first_visual = self.cursor_to_first_visual_row(logical_top); let line_height = self .get_visual_height_index() .map_or(1, |idx| idx.visual_height_of_line(logical_top)); self.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1))); self.v_sub_offset = 0; self.clamp_v_offset(); self.viewport_cache.invalidate(); } // Poll main indexer (Loading state) let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty); if let AppLoadingState::Loading { mut reader, estimated_lines, mut progress_percent, } = old_state { if let Some(msg) = reader.poll_indexer() { match msg { IndexerMessage::Progress { percent, .. } => { progress_percent = percent; self.loading_state = AppLoadingState::Loading { reader, estimated_lines, progress_percent, }; } IndexerMessage::Complete { reader: fr, visual_height_index, .. } => { let saved_cursor = self.cursor_line; reader.set_ready(fr, visual_height_index); self.loading_state = AppLoadingState::Ready { reader }; self.viewport_cache.invalidate(); // Clamp cursor if exact count < estimated self.cursor_line = saved_cursor.min(self.total_lines().saturating_sub(1)); // Loading uses 1:1 logical-line offsets; Ready uses visual-row // offsets derived from the prefix-sum index. Recompute v_offset // so the same logical line stays visible (falls back to 1:1 when // the index is absent, which is the case right after invalidate). self.v_offset = self.cursor_to_first_visual_row(self.cursor_line); self.clamp_v_offset(); self.v_sub_offset = 0; // Gutter width changes (~N → N) shift content_width, so any // VisualHeightIndex built with the old width is stale. let (new_offset, new_sub) = self.rebase_offset_for_invalidate(); if let AppLoadingState::Ready { reader } = &mut self.loading_state { reader.invalidate_visual_height_index(); } self.v_offset = new_offset; self.v_sub_offset = new_sub; if self.reload_after_loading { self.reload_after_loading = false; self.reload_ready_reader(); } } IndexerMessage::Error { message, .. } => { self.loading_state = AppLoadingState::Error(message); self.reload_after_loading = false; } } } else { self.loading_state = AppLoadingState::Loading { reader, estimated_lines, progress_percent, }; } } else { self.loading_state = old_state; } } } const TRUNCATE_TAB_WIDTH: usize = 4; fn truncate_to_columns(s: &str, max_cols: usize) -> String { if max_cols == 0 || s.is_empty() { return String::new(); } let mut out = String::new(); let mut col = 0; for ch in s.chars() { if ch == '\t' { let tab_stop = TRUNCATE_TAB_WIDTH - (col % TRUNCATE_TAB_WIDTH); if col + tab_stop > max_cols { break; } for _ in 0..tab_stop { out.push(' '); } col += tab_stop; } else { let w = if ch.is_control() { 0 } else { ch.width().unwrap_or(0) }; if col + w > max_cols { break; } out.push(ch); col += w; } } out } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); fn make_temp_file(content: &str) -> std::path::PathBuf { let id = COUNTER.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir(); let name = format!("log_viewer_test_{}_{}", std::process::id(), id); let path = dir.join(name); std::fs::write(&path, content).unwrap(); path } fn cleanup(path: &std::path::Path) { let _ = std::fs::remove_file(path); } fn load_file_ready(app: &mut App, path: &std::path::Path) { let data = std::fs::read(path).unwrap(); let index = log_viewer_core::io::line_index::LineIndex::from_bytes(&data); let _ = log_viewer_core::io::index_cache::IndexCache::save_with_hash(path, &index, &data); app.load_file(path.to_str().unwrap()).unwrap(); } #[test] fn test_app_new_defaults() { let app = App::new(); assert!(!app.should_quit); assert!(!app.is_loaded()); assert!(app.file_path.is_none()); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); assert!(app.viewport_cache.entries.is_empty()); assert_eq!(app.viewport_cache.width, 0); assert_eq!(app.content_width, 0); assert_eq!(app.content_height, 0); assert!(app.last_g_press.is_none()); } #[test] fn test_load_file_success() { let path = make_temp_file("line1\nline2\nline3\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); assert!(app.load_file(path.to_str().unwrap()).is_ok()); assert!(app.is_loaded()); assert_eq!(app.total_lines(), 3); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_resets_state() { let path = make_temp_file("a\nb\nc\nd\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.cursor_line = 3; app.v_offset = 2; let path2 = make_temp_file("x\ny\n"); app.load_file(path2.to_str().unwrap()).unwrap(); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); assert_eq!(app.total_lines(), 2); cleanup(&path2); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_nonexistent() { let mut app = App::new(); let result = app.load_file("/tmp/no_such_file_log_viewer_test_xyz"); assert!(result.is_err()); assert!(!app.is_loaded()); } #[test] fn test_unloaded_scroll_safety() { let mut app = App::new(); // None of these should panic app.scroll_down_line(); app.scroll_up_line(); app.scroll_down_half_page(); app.scroll_up_half_page(); app.scroll_down_page(); app.scroll_up_page(); app.scroll_to_top(); app.scroll_to_bottom(); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); } #[test] fn test_empty_file_scroll_safety() { let path = make_temp_file(""); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.scroll_down_line(); app.scroll_up_line(); app.scroll_down_half_page(); app.scroll_up_half_page(); app.scroll_down_page(); app.scroll_up_page(); app.scroll_to_top(); app.scroll_to_bottom(); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_scroll_down_at_bottom() { let path = make_temp_file("a\nb\nc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.cursor_line = 2; // last line app.v_offset = 2; app.scroll_down_line(); assert_eq!(app.cursor_line, 2); // stays at last }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_scroll_up_at_top() { let path = make_temp_file("a\nb\nc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert_eq!(app.cursor_line, 0); app.scroll_up_line(); assert_eq!(app.cursor_line, 0); // stays at 0 }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_viewport_cache_correctness() { let long_line = "a".repeat(200); let path = make_temp_file(&format!("{long_line}\nshort\n")); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; // enough viewport app.ensure_viewport_cache(20); // Long line should wrap into multiple visual rows let entry0 = app.viewport_cache.get_entry(0).unwrap(); assert!( entry0.visual_height > 1, "expected wrapping but got height {}", entry0.visual_height ); // Short line is 1 row let entry1 = app.viewport_cache.get_entry(1).unwrap(); assert_eq!(entry1.visual_height, 1); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_ensure_viewport_cache_not_loaded() { let mut app = App::new(); app.ensure_viewport_cache(80); // Should not panic and cache should stay empty assert!(app.viewport_cache.entries.is_empty()); } #[test] fn test_ensure_viewport_cache_zero_width() { let path = make_temp_file("hello\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.ensure_viewport_cache(0); // Should not panic, cache should stay empty (width 0 is guarded) assert!(app.viewport_cache.entries.is_empty()); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_resets_viewport_cache() { let path = make_temp_file("hello\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.viewport_cache.width = 80; app.load_file(path.to_str().unwrap()).unwrap(); assert_eq!(app.viewport_cache.width, 0); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_resets_gg_state() { let path = make_temp_file("hello\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.last_g_press = Some(Instant::now()); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.last_g_press.is_none()); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_json_format_default_off() { let app = App::new(); assert!(!app.json_format); } #[test] fn test_tab_toggles_json_format() { let path = make_temp_file("test\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); app.handle_key(tab); assert!(app.json_format, "Tab should toggle json_format to true"); assert_eq!(app.viewport_cache.width, 0, "Tab should invalidate viewport cache"); app.handle_key(tab); assert!(!app.json_format, "Second Tab should toggle back to false"); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_resets_json_format() { let path = make_temp_file("test\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.json_format = true; let path2 = make_temp_file("test2\n"); app.load_file(path2.to_str().unwrap()).unwrap(); assert!( !app.json_format, "load_file should reset json_format to false" ); cleanup(&path2); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_format_json_line_valid() { let result = format_json_line(r#"{"a":1,"b":2}"#); assert!( result.contains('\n'), "formatted JSON should contain newlines" ); assert!( result.contains(" "), "formatted JSON should contain 2-space indentation" ); assert!( result.contains("\"a\""), "formatted JSON should contain key a" ); assert!( result.contains("\"b\""), "formatted JSON should contain key b" ); } #[test] fn test_format_json_line_invalid() { let input = "hello world"; let result = format_json_line(input); assert_eq!(result, input, "plain text should be returned unchanged"); } #[test] fn test_format_json_line_empty() { let result = format_json_line(""); assert_eq!(result, "", "empty input should return empty string"); } #[test] fn test_format_json_line_array() { let input = "[1,2,3]"; let result = format_json_line(input); assert_eq!(result, input, "JSON array should be returned unchanged"); } #[test] fn test_format_json_line_primitives() { assert_eq!( format_json_line(r#""hello""#), r#""hello""#, "JSON string should be unchanged" ); assert_eq!( format_json_line("42"), "42", "JSON number should be unchanged" ); assert_eq!( format_json_line("true"), "true", "JSON boolean should be unchanged" ); assert_eq!( format_json_line("null"), "null", "JSON null should be unchanged" ); } #[test] fn test_format_json_line_whitespace_prefix() { let input = r#" {"a":1}"#; let result = format_json_line(input); assert!( result.contains('\n'), "JSON with leading whitespace should be formatted" ); assert!( result.contains("\"a\""), "formatted result should contain key a" ); } #[test] fn test_viewport_cache_with_json_format() { let json_content = r#"{"key1":"value1","key2":"value2"} plain text line {"nested":{"inner":true}} "#; let path = make_temp_file(json_content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; // First without formatting app.ensure_viewport_cache(80); let raw_heights: Vec = app .viewport_cache .entries .iter() .map(|e| e.visual_height) .collect(); // Now with formatting app.json_format = true; app.viewport_cache.invalidate(); app.ensure_viewport_cache(80); // JSON lines (0, 2) should have more visual rows when formatted let entry0 = app.viewport_cache.get_entry(0).unwrap(); let entry1 = app.viewport_cache.get_entry(1).unwrap(); let entry2 = app.viewport_cache.get_entry(2).unwrap(); assert!( entry0.visual_height > raw_heights[0], "JSON line 0 should have more visual rows when formatted" ); assert_eq!( entry1.visual_height, raw_heights[1], "Plain text line should have same visual rows" ); assert!( entry2.visual_height > raw_heights[2], "JSON line 2 should have more visual rows when formatted" ); // Check that wrapped rows contain indentation let json_line_wrapped: String = entry0.wrapped_rows.join(""); assert!( json_line_wrapped.contains(" "), "Formatted JSON wrap cache should contain indentation" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_viewport_cache_toggle_restore() { let json_content = r#"{"a":1,"b":2} "#; let path = make_temp_file(json_content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; // Compute raw wrap cache app.ensure_viewport_cache(80); let raw_rows: Vec> = app .viewport_cache .entries .iter() .map(|e| e.wrapped_rows.clone()) .collect(); // Toggle on: formatted app.json_format = true; app.viewport_cache.invalidate(); app.ensure_viewport_cache(80); let formatted_rows: Vec> = app .viewport_cache .entries .iter() .map(|e| e.wrapped_rows.clone()) .collect(); // Formatted should differ from raw assert_ne!( raw_rows, formatted_rows, "Formatted wrap cache should differ from raw" ); // Toggle off: back to raw app.json_format = false; app.viewport_cache.invalidate(); app.ensure_viewport_cache(80); let restored_rows: Vec> = app .viewport_cache .entries .iter() .map(|e| e.wrapped_rows.clone()) .collect(); assert_eq!( raw_rows, restored_rows, "Toggle off should restore original wrap cache" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_tab_toggle_keeps_cursor_visible() { let mut content = String::new(); for i in 0..100 { content.push_str(&format!(r#"{{"line":{},"data":"value{}"}}"#, i, i)); content.push('\n'); } let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 20; app.ensure_viewport_cache(80); let entry0 = app.viewport_cache.get_entry(0).unwrap(); assert_eq!(entry0.visual_height, 1); app.cursor_line = 50; app.ensure_cursor_visible(); let cursor_visual = app.cursor_to_first_visual_row(50); assert!( cursor_visual >= app.v_offset && cursor_visual < app.v_offset + app.content_height as usize, "cursor should be visible before Tab: cursor_visual={}, v_offset={}, content_height={}", cursor_visual, app.v_offset, app.content_height, ); let v_offset_before = app.v_offset; app.json_format = true; app.viewport_cache.invalidate(); app.ensure_viewport_cache(80); let height_0 = app.compute_visual_height(0, 80); assert!( height_0 > 1, "JSON lines should expand when formatted" ); let cursor_visual_after = app.cursor_to_first_visual_row(50); assert!( cursor_visual_after >= app.v_offset && cursor_visual_after < app.v_offset + app.content_height as usize, "cursor should be visible after Tab: cursor_visual={}, v_offset={}, content_height={}, v_offset_before={}", cursor_visual_after, app.v_offset, app.content_height, v_offset_before, ); assert_ne!( app.v_offset, v_offset_before, "v_offset should change after JSON formatting toggle (cursor was on line 50)" ); }); cleanup(&path); assert!(result.is_ok()); } // ── Task 3 tests: AppMode, color_config ─────────────────────── #[test] fn test_color_config_default_in_new() { let app = App::new(); assert_eq!(app.color_config, ColorConfig::default()); } #[test] fn test_app_mode_default_normal() { let app = App::new(); assert_eq!(app.mode, AppMode::Normal); } #[test] fn test_s_key_enters_settings() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut app = App::new(); let s_key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE); app.handle_key(s_key); assert_eq!(app.mode, AppMode::Settings); } #[test] fn test_load_file_resets_mode() { let path = make_temp_file("test\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.mode = AppMode::Settings; app.load_file(path.to_str().unwrap()).unwrap(); assert_eq!(app.mode, AppMode::Normal); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_populated() { let path = make_temp_file("ERROR: fail\nWARN: maybe\njust text\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; app.ensure_viewport_cache(80); assert!(!app.viewport_cache.entries.is_empty()); assert_eq!(app.viewport_cache.entries.len(), 3); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_cleared_on_reload() { let path_a = make_temp_file("ERROR: a\nINFO: b\n"); let path_b = make_temp_file("WARN: c\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path_a.to_str().unwrap()).unwrap(); app.content_height = 50; app.ensure_viewport_cache(80); assert_eq!(app.viewport_cache.entries.len(), 2); app.load_file(path_b.to_str().unwrap()).unwrap(); app.ensure_viewport_cache(80); assert_eq!(app.viewport_cache.entries.len(), 1); cleanup(&path_b); }); cleanup(&path_a); assert!(result.is_ok()); } #[test] fn test_level_cache_json_level() { let line = r#"{"level":"ERROR","message":"fail"}"#; let path = make_temp_file(&format!("{line}\n")); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; app.ensure_viewport_cache(80); let entry = app.viewport_cache.get_entry(0).unwrap(); assert_eq!(entry.level, Some(LogLevel::Error)); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_plain_text() { let path = make_temp_file("ERROR: something\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; app.ensure_viewport_cache(80); let entry = app.viewport_cache.get_entry(0).unwrap(); assert_eq!(entry.level, Some(LogLevel::Error)); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_uses_raw_line() { let json_line = r#"{"level":"WARN","msg":"careful"}"#; let path = make_temp_file(&format!("{json_line}\n")); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.json_format = true; app.content_height = 50; app.ensure_viewport_cache(80); let entry = app.viewport_cache.get_entry(0).unwrap(); assert_eq!(entry.level, Some(LogLevel::Warn)); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_entries_cover_all_lines() { let path = make_temp_file("ERROR: a\nINFO: b\nDEBUG: c\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; app.ensure_viewport_cache(80); assert_eq!( app.viewport_cache.entries.len(), 3, "viewport cache entries should cover all lines" ); }); cleanup(&path); assert!(result.is_ok()); } // ── Task 5 tests: Settings panel ──────────────────────────────── fn make_key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE) } fn enter_settings(app: &mut App) { app.handle_key(make_key(crossterm::event::KeyCode::Char('s'))); } #[test] fn test_settings_enter() { let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.mode, AppMode::Settings); } #[test] fn test_settings_esc_cancel() { let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.settings_draft, app.color_config); app.handle_key(make_key(crossterm::event::KeyCode::Right)); app.handle_key(make_key(crossterm::event::KeyCode::Esc)); assert_eq!(app.mode, AppMode::Normal); assert_eq!( app.color_config, ColorConfig::default(), "Esc should NOT save changes" ); } #[test] fn test_settings_enter_save() { let mut app = App::new(); let original = app.color_config.clone(); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert_ne!( app.settings_draft.error, original.error, "Right should change draft" ); app.handle_key(make_key(crossterm::event::KeyCode::Enter)); assert_eq!(app.mode, AppMode::Normal); assert_eq!( app.color_config, app.settings_draft, "Enter should save draft to config" ); } #[test] fn test_settings_navigation() { let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.settings_cursor, 0); app.handle_key(make_key(crossterm::event::KeyCode::Char('j'))); assert_eq!(app.settings_cursor, 1); app.handle_key(make_key(crossterm::event::KeyCode::Down)); assert_eq!(app.settings_cursor, 2); app.handle_key(make_key(crossterm::event::KeyCode::Char('k'))); assert_eq!(app.settings_cursor, 1); app.handle_key(make_key(crossterm::event::KeyCode::Up)); assert_eq!(app.settings_cursor, 0); } #[test] fn test_settings_cycle_color() { let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.settings_draft.error, "red"); app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert_eq!(app.settings_draft.error, "green"); app.handle_key(make_key(crossterm::event::KeyCode::Left)); assert_eq!(app.settings_draft.error, "red"); // backward wrap: red (index 0) → white (index 7) app.handle_key(make_key(crossterm::event::KeyCode::Left)); assert_eq!(app.settings_draft.error, "white"); // forward wrap: white (index 7) → red (index 0) app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert_eq!(app.settings_draft.error, "red"); } #[test] fn test_settings_number_key() { let mut app = App::new(); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Char('1'))); assert_eq!(app.settings_draft.error, "red"); app.handle_key(make_key(crossterm::event::KeyCode::Char('4'))); assert_eq!(app.settings_draft.error, "blue"); app.handle_key(make_key(crossterm::event::KeyCode::Char('8'))); assert_eq!(app.settings_draft.error, "white"); } #[test] fn test_settings_cursor_boundary() { let mut app = App::new(); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Char('k'))); assert_eq!(app.settings_cursor, 0, "k at 0 should stay 0"); app.settings_cursor = 5; app.handle_key(make_key(crossterm::event::KeyCode::Char('j'))); assert_eq!(app.settings_cursor, 5, "j at 5 should stay 5"); } #[test] fn test_settings_q_cancels() { let mut app = App::new(); let original = app.color_config.clone(); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Right)); app.handle_key(make_key(crossterm::event::KeyCode::Char('q'))); assert_eq!(app.mode, AppMode::Normal); assert_eq!(app.color_config, original, "q should NOT save changes"); } #[test] fn test_settings_cycle_unknown_color() { let mut app = App::new(); enter_settings(&mut app); app.settings_draft.error = "not_in_list".to_string(); app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert_eq!( app.settings_draft.error, "red", "unknown color Right should go to first (red)" ); app.settings_draft.error = "not_in_list".to_string(); app.handle_key(make_key(crossterm::event::KeyCode::Left)); assert_eq!( app.settings_draft.error, "white", "unknown color Left should go to last (white)" ); } #[test] fn test_settings_draft_synced_on_enter() { let mut app = App::new(); app.color_config.error = "magenta".to_string(); let expected_draft = app.color_config.clone(); enter_settings(&mut app); assert_eq!( app.settings_draft, expected_draft, "draft should sync from color_config on S press" ); } #[test] fn test_settings_save_failure_stays_in_settings() { let mut app = App::new(); let original = app.color_config.clone(); let impossible_path = std::path::PathBuf::from("/nonexistent/deep/nested/config.toml"); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert_ne!(app.settings_draft.error, original.error); let draft = app.settings_draft.clone(); let result = draft.save_to(&impossible_path); assert!(result.is_err(), "Save to impossible path should fail"); let err = result.unwrap_err(); assert!( err.to_string().contains("config") || err.to_string().contains("creating"), "Error should mention config or creating directory" ); } #[test] fn test_settings_error_cleared_on_edit() { let mut app = App::new(); enter_settings(&mut app); app.settings_error = Some("test error".to_string()); app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert!( app.settings_error.is_none(), "Editing a setting should clear settings_error" ); } #[test] fn test_settings_error_cleared_on_esc() { let mut app = App::new(); enter_settings(&mut app); app.settings_error = Some("test error".to_string()); app.handle_key(make_key(crossterm::event::KeyCode::Esc)); assert_eq!(app.mode, AppMode::Normal); assert!( app.settings_error.is_none(), "Esc should clear settings_error" ); } #[test] fn test_settings_error_cleared_on_enter_settings() { let mut app = App::new(); enter_settings(&mut app); app.settings_error = Some("stale error".to_string()); app.handle_key(make_key(crossterm::event::KeyCode::Esc)); assert!(app.settings_error.is_none()); enter_settings(&mut app); assert!( app.settings_error.is_none(), "Entering settings should clear stale errors" ); } #[test] fn test_color_change_affects_build_line_spans() { use crate::ui::build_line_spans; use ratatui::style::Color; let mut config = ColorConfig::default(); config.error = "blue".to_string(); let line = build_line_spans( "1 │".to_string(), "hello".to_string(), false, Some(&LogLevel::Error), &config, ); assert_eq!(line.spans[1].style.fg, Some(Color::Blue)); } // ── T14: Seamless Loading→Ready transition ──────────────────────── fn app_in_loading_state( file_path: &std::path::Path, generation: u64, ) -> ( App, crossbeam_channel::Sender, ) { if let Some(cp) = log_viewer_core::io::cache_util::cache_path(file_path) { let _ = std::fs::remove_file(cp); } let (tx, rx) = crossbeam_channel::bounded(10); let (cancel_tx, _cancel_rx) = crossbeam_channel::bounded(1); let reader = ProgressiveFileReader::with_channels( file_path, cancel_tx, rx, generation, ) .unwrap(); let estimated = reader.line_count() as u64; let mut app = App::new(); app.file_path = Some(file_path.to_str().unwrap().to_string()); app.loading_state = AppLoadingState::Loading { reader, estimated_lines: estimated, progress_percent: 0.0, }; (app, tx) } fn file_reader_for(path: &std::path::Path) -> log_viewer_core::io::file_reader::FileReader { log_viewer_core::io::file_reader::FileReader::open(path).unwrap() } #[test] fn test_seamless_transition_preserves_position() { let content: String = (0..100) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let (mut app, tx) = app_in_loading_state(&path, 42); app.cursor_line = 50; app.v_offset = 50; let fr = file_reader_for(&path); tx.send(IndexerMessage::Complete { generation: 42, reader: fr, visual_height_index: None, }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading(), "should be Ready"); assert_eq!(app.cursor_line, 50, "cursor preserved at line 50"); assert_eq!(app.total_lines(), 100); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_seamless_transition_clamps_cursor() { let big_content: String = (0..100) .map(|i| format!("line {}\n", i)) .collect(); let small_content: String = (0..80) .map(|i| format!("line {}\n", i)) .collect(); let big_path = make_temp_file(&big_content); let small_path = make_temp_file(&small_content); let result = std::panic::catch_unwind(|| { let (mut app, tx) = app_in_loading_state(&big_path, 42); app.cursor_line = 90; app.v_offset = 90; let fr = file_reader_for(&small_path); tx.send(IndexerMessage::Complete { generation: 42, reader: fr, visual_height_index: None, }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading()); assert_eq!(app.total_lines(), 80); assert_eq!(app.cursor_line, 79, "clamped to last valid line"); assert!(app.v_offset <= app.cursor_line); }); cleanup(&big_path); cleanup(&small_path); assert!(result.is_ok()); } #[test] fn test_seamless_transition_v_offset_converted() { let content: String = (0..50) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let (mut app, tx) = app_in_loading_state(&path, 7); app.content_height = 20; app.cursor_line = 30; app.v_offset = 30; let fr = file_reader_for(&path); tx.send(IndexerMessage::Complete { generation: 7, reader: fr, visual_height_index: None, }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading()); assert_eq!(app.cursor_line, 30); assert_eq!(app.v_offset, 30, "v_offset = cursor_to_first_visual_row(30)"); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_seamless_transition_invalidates_visual_height_index() { let content: String = (0..30) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let (mut app, tx) = app_in_loading_state(&path, 1); let fr = file_reader_for(&path); let visual_heights = vec![1usize; 30]; let vhi = VisualHeightIndex::build(&visual_heights); tx.send(IndexerMessage::Complete { generation: 1, reader: fr, visual_height_index: Some(vhi), }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading()); assert!( app.get_visual_height_index().is_none(), "visual height index should be invalidated (gutter width change)" ); }); cleanup(&path); assert!(result.is_ok()); } // ── FileWatcher integration tests ──────────────────────────────── #[test] fn test_file_watcher_started_on_load() { let path = make_temp_file("line1\nline2\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!( app.file_watcher.is_some(), "file_watcher should be started after load_file" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_file_watcher_stopped_on_new_file() { let path_a = make_temp_file("aaa\n"); let path_b = make_temp_file("bbb\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path_a.to_str().unwrap()).unwrap(); assert!(app.file_watcher.is_some()); app.load_file(path_b.to_str().unwrap()).unwrap(); // After loading a new file, watcher should be watching the new file assert!( app.file_watcher.is_some(), "file_watcher should be started for new file" ); assert_eq!(app.total_lines(), 1); assert_eq!(app.get_line(0), Some("bbb".to_string())); }); cleanup(&path_a); cleanup(&path_b); assert!(result.is_ok()); } #[test] fn test_file_watcher_stopped_on_nonexistent_file() { let path = make_temp_file("data\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.file_watcher.is_some()); let _ = app.load_file("/tmp/no_such_file_log_viewer_test_xyz_999"); assert!( app.file_watcher.is_none(), "file_watcher should be None after failed load_file" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_file_watcher_append_updates_index() { let path = make_temp_file("line1\nline2\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); assert_eq!(app.total_lines(), 2); // Append externally { use std::io::Write; let mut f = std::fs::OpenOptions::new() .append(true) .open(&path) .unwrap(); f.write_all(b"line3\nline4\n").unwrap(); } // Give the watcher time to detect the change std::thread::sleep(std::time::Duration::from_millis(500)); app.poll_file_watcher(); assert_eq!( app.total_lines(), 4, "total_lines should increase after append" ); assert_eq!(app.get_line(2), Some("line3".to_string())); assert_eq!(app.get_line(3), Some("line4".to_string())); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_file_watcher_truncate_reloads() { let path = make_temp_file("aaa\nbbb\nccc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); assert_eq!(app.total_lines(), 3); app.cursor_line = 2; // Truncate externally { let _ = std::fs::File::create(&path).unwrap(); } // Give the watcher time to detect the change std::thread::sleep(std::time::Duration::from_millis(500)); app.poll_file_watcher(); assert_eq!( app.total_lines(), 0, "total_lines should be 0 after truncate" ); // Cursor should be clamped assert!( app.cursor_line == 0, "cursor should be clamped after truncate" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_file_watcher_no_events_no_change() { let path = make_temp_file("stable\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); let lines_before = app.total_lines(); std::thread::sleep(std::time::Duration::from_millis(200)); app.poll_file_watcher(); assert_eq!( app.total_lines(), lines_before, "no events should mean no change" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_poll_file_watcher_no_watcher() { let mut app = App::new(); // Should not panic app.poll_file_watcher(); } // ── Loading + JSON expansion tests ───────────────────────────── #[test] fn test_tab_toggle_during_loading() { let content = "line1\n{\"ts\":\"2025\",\"level\":\"ERROR\",\"msg\":\"hello\"}\nline3\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading(), "should be in Loading state"); use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); app.handle_key(tab); assert!(app.json_format, "Tab should toggle json_format to true during Loading"); assert_eq!( app.viewport_cache.width, 0, "Tab should invalidate viewport cache (width==0)" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_expanded_visual_height() { let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; let content = format!("line1\n{json_line}\nline3\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; let height = app.compute_visual_height(1, 40); assert!( height > 1, "JSON expanded line should have visual_height > 1, got {height}" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_viewport_contains_cursor() { let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; let mut lines: Vec = (0..50).map(|i| format!("line{i}")).collect(); lines.push(json_line.to_string()); lines.push("end".to_string()); let content = lines.join("\n") + "\n"; let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; app.cursor_line = 50; app.ensure_viewport_cache(80); let entries = &app.viewport_cache.entries; let first = app.viewport_cache.logical_start; let last_logical = first + entries.len().saturating_sub(1); assert!( app.cursor_line >= first && app.cursor_line <= last_logical, "cursor_line {} should be within viewport range [{}, {}]", app.cursor_line, first, last_logical ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_scroll_down_line() { let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#; let content = format!("line1\n{json_line}\nline3\nline4\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; assert_eq!(app.cursor_line, 0); app.scroll_down_line(); assert_eq!( app.cursor_line, 1, "scroll_down_line should increment cursor_line by 1" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_scroll_half_page() { let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#; let content = format!("line1\n{json_line}\nline3\nline4\nline5\nline6\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; // Should not panic app.scroll_down_half_page(); let total = app.total_lines(); assert!( app.cursor_line < total, "cursor_line {} should be < total_lines {}", app.cursor_line, total ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_scroll_to_bottom() { let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#; let content = format!("line1\n{json_line}\nline3\nline4\nline5\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; // Should not panic app.scroll_to_bottom(); assert_eq!( app.cursor_line, app.total_lines() - 1, "scroll_to_bottom should set cursor_line to last line" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_to_ready_preserves_json_format() { let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; let content = format!("line1\n{json_line}\nline3\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading(), "should start in Loading state"); // load_file resets json_format to false app.json_format = true; app.content_height = 24; // Manually transition Loading → Ready let old_state = std::mem::replace(&mut app.loading_state, AppLoadingState::Empty); if let AppLoadingState::Loading { reader, .. } = old_state { app.loading_state = AppLoadingState::Ready { reader }; } app.viewport_cache.invalidate(); assert!(!app.is_loading(), "should now be in Ready state"); app.ensure_viewport_cache(80); assert!( app.json_format, "json_format should remain true after Loading→Ready transition" ); let has_expanded = app.viewport_cache.entries.iter().any(|e| e.visual_height > 1); assert!( has_expanded, "viewport should contain entries with visual_height > 1 (JSON expanded)" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_tab_toggle_off_during_loading() { let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#; let content = format!("line1\n{json_line}\nline3\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); // Tab ON app.handle_key(tab); assert!(app.json_format, "first Tab should set json_format=true"); // Tab OFF app.handle_key(tab); assert!(!app.json_format, "second Tab should set json_format=false"); app.content_height = 24; app.ensure_viewport_cache(80); for (i, entry) in app.viewport_cache.entries.iter().enumerate() { assert_eq!( entry.visual_height, 1, "entry {} should have height 1 with json_format off", i ); } }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_empty_line() { let content = "line1\n\nline3\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; let height = app.compute_visual_height(1, 80); assert_eq!( height, 1, "empty line should have visual_height 1, got {height}" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_deep_nested() { let deep_json = r#"{"a":{"b":{"c":{"d":"value"}}}}"#; let content = format!("line1\n{deep_json}\nline3\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; app.ensure_viewport_cache(20); let json_height = app.compute_visual_height(1, 20); assert!( json_height > 1, "deep nested JSON at width 20 should have visual_height > 1, got {json_height}" ); let total_rows: usize = app.viewport_cache.entries.iter().map(|e| e.visual_height).sum(); assert!( total_rows <= app.content_height as usize + 2, "viewport visual rows ({total_rows}) should not wildly exceed content_height ({})", app.content_height ); }); cleanup(&path); assert!(result.is_ok()); } /// Regression test for issue #24: /// ensure_viewport_cache must use the updated self.v_offset after params_changed /// recalculates it, not a stale local captured before the change block. #[test] fn test_loading_viewport_cache_uses_updated_v_offset_on_params_changed() { let content: String = (0..200).map(|i| format!("line{i}\n")).collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading(), "should be in Loading state"); app.content_height = 10; app.ensure_viewport_cache(80); app.v_offset = 90; app.cursor_line = 100; let new_width = 40; app.ensure_viewport_cache(new_width); let recomputed_offset = app.v_offset; assert_ne!( recomputed_offset, 90, "v_offset should have been recalculated by params_changed block, still 90" ); assert_eq!( app.viewport_cache.logical_start, recomputed_offset.min(app.total_lines().saturating_sub(1)), "logical_start should match the updated v_offset" ); }); cleanup(&path); assert!(result.is_ok()); } fn install_vhi(app: &mut App, heights: &[usize]) { let width = app.get_content_width(); let json_format = app.json_format; let vhi = VisualHeightIndex::build(heights).with_params(json_format, width); if let AppLoadingState::Ready { reader } = &mut app.loading_state { if let log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index, .. } = &mut reader.state { *visual_height_index = Some(vhi); } } } #[test] fn test_vhi_scroll_down_line_visual_row() { let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; // Each line wraps to 3 visual rows → 30 total, overflows viewport of 10 install_vhi(&mut app, &[3usize; 10]); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); for _ in 0..5 { app.scroll_down_line(); } assert_eq!(app.v_offset, 5, "v_offset should be 5 after 5 visual scrolls"); // center_visual = 5 + 10/2 = 10 → maps to logical line 3 (visual rows: line0=0-2, line1=3-5, line2=6-8, line3=9-11) assert_eq!(app.cursor_line, 3, "cursor should track center at logical line 3"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_vhi_scroll_up_line_visual_row() { let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; install_vhi(&mut app, &[3usize; 10]); // Start at v_offset=5, cursor_line=3 (matching center) app.v_offset = 5; app.cursor_line = 3; for _ in 0..5 { app.scroll_up_line(); } assert_eq!(app.v_offset, 0, "v_offset should return to 0"); // After scrolling back up, center_visual = 0 + 5 = 5 → line1 assert!(app.cursor_line <= 2, "cursor should be near top, got {}", app.cursor_line); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_vhi_scroll_down_line_small_file_fallback() { let content = "a\nb\nc\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 24; // 3 lines, 1 row each → 3 visual rows, fits in viewport of 24 install_vhi(&mut app, &[1usize; 3]); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); app.scroll_down_line(); assert_eq!(app.cursor_line, 1, "cursor should move to line 1 (logical)"); assert_eq!(app.v_offset, 0, "v_offset should stay 0 (content fits)"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_vhi_scroll_j_k_roundtrip() { let content: String = (0..20).map(|i| format!("line{}\n", i)).collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; // 20 lines × 2 rows = 40 visual rows, viewport = 10 install_vhi(&mut app, &[2usize; 20]); let initial_cursor = app.cursor_line; let initial_offset = app.v_offset; for _ in 0..15 { app.scroll_down_line(); } assert!(app.v_offset > 0, "v_offset should have moved down"); for _ in 0..15 { app.scroll_up_line(); } assert_eq!(app.v_offset, initial_offset, "v_offset should roundtrip to {}", initial_offset); assert!( app.cursor_line <= initial_cursor + 3, "cursor should return near top, got {}, expected <= {}", app.cursor_line, initial_cursor + 3 ); cleanup(&path); }); assert!(result.is_ok()); } fn app_in_loading_with_long_lines(app: &mut App, line_count: usize, line_width: usize) -> std::path::PathBuf { let content: String = (0..line_count) .map(|_| "x".repeat(line_width)) .collect::>() .join("\n"); let path = make_temp_file(&content); app.load_file(path.to_str().unwrap()).unwrap(); path } #[test] fn test_loading_scroll_down_advances_sub_offset() { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut app = App::new(); let path = app_in_loading_with_long_lines(&mut app, 10000, 200); app.content_height = 24; if !app.is_loading() { cleanup(&path); return; } assert_eq!(app.v_offset, 0); assert_eq!(app.v_sub_offset, 0); app.scroll_down_line(); assert_eq!(app.v_offset, 0, "v_offset should stay 0 after first j"); assert_eq!(app.v_sub_offset, 1, "v_sub_offset should advance to 1"); app.scroll_down_line(); assert_eq!(app.v_offset, 0, "v_offset should stay 0 after second j"); assert_eq!(app.v_sub_offset, 2, "v_sub_offset should advance to 2"); app.scroll_down_line(); assert_eq!(app.v_offset, 1, "v_offset should advance to 1"); assert_eq!(app.v_sub_offset, 0, "v_sub_offset should reset to 0"); cleanup(&path); })); assert!(result.is_ok()); } #[test] fn test_loading_scroll_up_decrements_sub_offset() { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut app = App::new(); let path = app_in_loading_with_long_lines(&mut app, 10000, 200); app.content_height = 24; if !app.is_loading() { cleanup(&path); return; } // Scroll down to v_offset=1, v_sub_offset=0 for _ in 0..3 { app.scroll_down_line(); } assert_eq!(app.v_offset, 1); assert_eq!(app.v_sub_offset, 0); app.scroll_up_line(); assert_eq!(app.v_offset, 0, "v_offset should go back to 0"); assert!(app.v_sub_offset > 0, "v_sub_offset should be at end of line 0"); let prev_sub = app.v_sub_offset; app.scroll_up_line(); assert_eq!(app.v_offset, 0, "v_offset should stay 0"); assert_eq!(app.v_sub_offset, prev_sub - 1); cleanup(&path); })); assert!(result.is_ok()); } #[test] fn test_loading_scroll_to_top_resets_sub_offset() { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut app = App::new(); let path = app_in_loading_with_long_lines(&mut app, 10000, 200); app.content_height = 24; if !app.is_loading() { cleanup(&path); return; } for _ in 0..5 { app.scroll_down_line(); } assert!(app.v_offset > 0 || app.v_sub_offset > 0); app.scroll_to_top(); assert_eq!(app.v_offset, 0, "v_offset should be 0 after scroll_to_top"); assert_eq!(app.v_sub_offset, 0, "v_sub_offset should be 0 after scroll_to_top"); cleanup(&path); })); assert!(result.is_ok()); } // ── H10 regression: file events during Loading state ────────────── #[test] fn test_append_during_loading_sets_reload_flag() { let content: String = (0..50) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let (mut app, _tx) = app_in_loading_state(&path, 1); assert!(app.is_loading()); assert!(!app.reload_after_loading); app.handle_file_appended(); assert!(app.reload_after_loading, "append during Loading should set reload_after_loading flag"); })); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_truncate_during_loading_sets_reload_flag() { let content: String = (0..50) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let (mut app, _tx) = app_in_loading_state(&path, 1); assert!(app.is_loading()); app.handle_file_truncated(); assert!(app.reload_after_loading, "truncate during Loading should set reload_after_loading flag"); })); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_multiple_events_during_loading_collapse_to_single_reload() { let content: String = (0..50) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let (mut app, tx) = app_in_loading_state(&path, 1); app.handle_file_appended(); app.handle_file_truncated(); app.handle_file_appended(); assert!(app.reload_after_loading); let updated_content: String = (0..60) .map(|i| format!("updated line {}\n", i)) .collect(); std::fs::write(&path, &updated_content).unwrap(); let fr = file_reader_for(&path); tx.send(IndexerMessage::Complete { generation: 1, reader: fr, visual_height_index: None, }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading(), "should be Ready after Complete"); assert!(!app.reload_after_loading, "flag should be cleared"); assert_eq!(app.total_lines(), 60, "should show reloaded content, not stale indexer result"); })); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_indexer_error_clears_reload_flag() { let content: String = (0..10) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let (mut app, tx) = app_in_loading_state(&path, 1); app.handle_file_appended(); assert!(app.reload_after_loading); tx.send(IndexerMessage::Error { generation: 1, message: "test error".into(), }).unwrap(); app.poll_background_indexer(); assert!(matches!(app.loading_state, AppLoadingState::Error(_))); assert!(!app.reload_after_loading, "flag should be cleared on indexer error"); })); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_append_no_trailing_newline_updates_last_line_height() { let path = make_temp_file("abc"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); assert_eq!(app.total_lines(), 1); app.content_width = 5; install_vhi(&mut app, &[1usize]); { let vhi = app.get_visual_height_index().unwrap(); assert_eq!(vhi.visual_height_of_line(0), 1); assert_eq!(vhi.total_visual_rows(), 1); } // Append content that extends line 0 (no trailing newline before) // "abc" + "defgh\n" = "abcdefgh\n" → 8 chars in width 5 → wraps to 2 visual rows // old_total=1, old_had_trailing=false → starts_new_line=false // new_has_trailing=true, new_newlines=1 → added = 1-1 = 0 // Still 1 logical line, but line 0 text changed from "abc" to "abcdefgh" { use std::io::Write; let mut f = std::fs::OpenOptions::new() .append(true) .open(&path) .unwrap(); f.write_all(b"defgh\n").unwrap(); } std::thread::sleep(std::time::Duration::from_millis(500)); app.poll_file_watcher(); assert_eq!(app.total_lines(), 1, "\"abcdefgh\\n\" has trailing newline → 1 logical line"); let vhi = app.get_visual_height_index().expect("VHI should still exist after append"); assert_eq!(vhi.visual_height_of_line(0), 2, "line 0 height should be updated from 1 to 2 after extending 'abcdefgh' in width 5"); assert_eq!(vhi.total_visual_rows(), 2); assert_eq!(vhi.cursor_to_first_visual_row(0), 0); cleanup(&path); }); assert!(result.is_ok()); } // ── Issue #23: KeyEventKind filtering ─────────────────────────── fn make_key_with_kind( code: crossterm::event::KeyCode, modifiers: crossterm::event::KeyModifiers, kind: crossterm::event::KeyEventKind, ) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new_with_kind(code, modifiers, kind) } #[test] fn issue23_release_quit_ignored() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let mut app = App::new(); let release_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Release); app.handle_key(release_q); assert!(!app.should_quit, "Release+q must NOT quit"); } #[test] fn issue23_release_scroll_ignored() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let path = make_temp_file("a\nb\nc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); let release_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Release); app.handle_key(release_j); assert_eq!(app.cursor_line, 0, "Release+j must NOT scroll"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn issue23_repeat_quit_ignored() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let mut app = App::new(); let repeat_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Repeat); app.handle_key(repeat_q); assert!(!app.should_quit, "Repeat+q must NOT quit"); } #[test] fn issue23_repeat_j_passes() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let path = make_temp_file("a\nb\nc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); let repeat_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Repeat); app.handle_key(repeat_j); assert_eq!(app.cursor_line, 1, "Repeat+j must scroll"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn issue23_repeat_ctrl_d_passes() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let path = make_temp_file("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.content_height = 5; load_file_ready(&mut app, &path); install_vhi(&mut app, &[1usize; 10]); let repeat_ctrl_d = make_key_with_kind( KeyCode::Char('d'), KeyModifiers::CONTROL, KeyEventKind::Repeat, ); app.handle_key(repeat_ctrl_d); assert!(app.cursor_line > 0, "Repeat+Ctrl+d must scroll half page"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn issue23_repeat_ctrl_d_without_ctrl_ignored() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let path = make_temp_file("a\nb\nc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); let repeat_plain_d = make_key_with_kind( KeyCode::Char('d'), KeyModifiers::NONE, KeyEventKind::Repeat, ); app.handle_key(repeat_plain_d); assert_eq!(app.cursor_line, 0, "Repeat+plain d must NOT scroll"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn issue23_repeat_g_ignored() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let path = make_temp_file("a\nb\nc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); let repeat_g = make_key_with_kind(KeyCode::Char('g'), KeyModifiers::NONE, KeyEventKind::Repeat); app.handle_key(repeat_g); assert_eq!(app.cursor_line, 0, "Repeat+g must NOT jump"); assert!(app.last_g_press.is_none(), "Repeat+g must not set last_g_press"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn issue23_settings_repeat_left_right_passes() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.mode, AppMode::Settings); let repeat_right = make_key_with_kind(KeyCode::Right, KeyModifiers::NONE, KeyEventKind::Repeat); app.handle_key(repeat_right); app.handle_key(make_key(KeyCode::Enter)); assert_eq!(app.mode, AppMode::Normal, "Repeat Right in Settings should work then Enter closes"); } #[test] fn issue23_settings_repeat_enter_ignored() { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let mut app = App::new(); enter_settings(&mut app); let repeat_enter = make_key_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat); app.handle_key(repeat_enter); assert_eq!(app.mode, AppMode::Settings, "Repeat+Enter must NOT close settings"); } #[test] fn test_append_no_trailing_newline_no_new_lines_only_height_change() { let path = make_temp_file("abc"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); assert_eq!(app.total_lines(), 1); app.content_width = 5; install_vhi(&mut app, &[1usize]); // Append without adding any new line — just extends line 0 // "abc" + "def" = "abcdef" → 6 chars in width 5 → wraps to 2 visual rows, still 1 logical line { use std::io::Write; let mut f = std::fs::OpenOptions::new() .append(true) .open(&path) .unwrap(); f.write_all(b"def").unwrap(); } std::thread::sleep(std::time::Duration::from_millis(500)); app.poll_file_watcher(); assert_eq!(app.total_lines(), 1, "no new logical line should be added"); let vhi = app.get_visual_height_index().expect("VHI should still exist"); assert_eq!(vhi.visual_height_of_line(0), 2, "line 0 height should update even when no new lines added"); assert_eq!(vhi.total_visual_rows(), 2); cleanup(&path); }); assert!(result.is_ok()); } // ── Issue #25 regression tests ────────────────────────────────── // Ready state without VHI must keep v_offset self-consistent. #[test] fn test_rebase_offset_converts_visual_to_logical_with_sub() { let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; // 10 lines × 3 visual rows each = 30 visual rows install_vhi(&mut app, &[3usize; 10]); // v_offset=7 → visual row 7 is inside line2 (rows 6-8), sub=1 app.v_offset = 7; app.v_sub_offset = 0; let (logical, sub) = app.rebase_offset_for_invalidate(); assert_eq!(logical, 2, "visual row 7 → logical line 2 (rows 6-8)"); assert_eq!(sub, 1, "visual row 7 is sub-row 1 within line 2"); // v_offset=0 → line 0, sub=0 app.v_offset = 0; let (logical, sub) = app.rebase_offset_for_invalidate(); assert_eq!(logical, 0); assert_eq!(sub, 0); // v_offset=5 → line1 (rows 3-5), sub=2 app.v_offset = 5; let (logical, sub) = app.rebase_offset_for_invalidate(); assert_eq!(logical, 1, "visual row 5 → logical line 1 (rows 3-5)"); assert_eq!(sub, 2); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_rebase_offset_passthrough_when_no_vhi() { let content = "line0\nline1\nline2\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); // No VHI installed — rebase should return current values unchanged app.v_offset = 42; app.v_sub_offset = 3; let (logical, sub) = app.rebase_offset_for_invalidate(); assert_eq!(logical, 42); assert_eq!(sub, 3); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_tab_toggle_rebases_offset_before_invalidate() { let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; app.content_width = 20; install_vhi(&mut app, &[3usize; 10]); // Scroll to visual row 7 → line2 sub-row 1 app.v_offset = 7; app.cursor_line = 2; app.v_sub_offset = 0; // Simulate Tab toggle: rebase, then invalidate let rebased = app.rebase_offset_for_invalidate(); let (new_offset, new_sub) = rebased; app.v_offset = new_offset; app.v_sub_offset = new_sub; assert_eq!(app.v_offset, 2, "v_offset should be logical line 2 after rebase"); assert_eq!(app.v_sub_offset, 1, "v_sub_offset should preserve sub-row 1"); // Key invariant: v_offset is now a valid logical line number, // not a stale visual-row offset that would cause a jump. // Before the fix, v_offset would still be 7 (visual) treated as logical → jump. assert!( app.v_offset < app.total_lines(), "v_offset ({}) must be a valid logical line index < {}", app.v_offset, app.total_lines() ); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_vhi_rebuild_recalibrates_from_viewport_top() { let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; app.content_width = 20; // Install VHI with varying heights install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]); // Scroll to visual row 8 → line2 (rows 6-8), sub=2 app.v_offset = 8; app.cursor_line = 2; app.v_sub_offset = 0; // Rebase (simulates invalidate) let (logical_top, sub) = app.rebase_offset_for_invalidate(); assert_eq!(logical_top, 2); assert_eq!(sub, 2); // Simulate what happens: v_offset becomes logical, VHI cleared app.v_offset = logical_top; app.v_sub_offset = sub; // Now simulate VHI rebuild completion — the recalibration block // Install a fresh VHI (same heights, simulates rebuild result) install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]); // The recalibration logic from poll_background_indexer let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1)); let sub = app.v_sub_offset; let first_visual = app.cursor_to_first_visual_row(logical_top); let line_height = app .get_visual_height_index() .map_or(1, |idx| idx.visual_height_of_line(logical_top)); app.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1))); app.v_sub_offset = 0; // line2 starts at visual row 6, sub was 2 → visual row 8 assert_eq!(app.v_offset, 8, "v_offset should map back to visual row 8"); assert_eq!(app.v_sub_offset, 0, "v_sub_offset should be 0 after recalibration"); // Scrolling should work normally with VHI app.scroll_down_line(); assert_eq!(app.v_offset, 9, "scroll down should advance visual row"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_vhi_rebuild_clamps_sub_to_new_line_height() { let content = "line0\nline1\nline2\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 24; app.content_width = 20; // Line heights: line0=1, line1=5 (wraps), line2=1 install_vhi(&mut app, &[1, 5, 1]); // Position at line1 sub-row 4 (last sub-row of 5-row line) app.v_offset = 5; app.cursor_line = 1; app.v_sub_offset = 0; // Rebase let (logical_top, sub) = app.rebase_offset_for_invalidate(); assert_eq!(logical_top, 1, "visual row 5 → line 1 (rows 1-5)"); assert_eq!(sub, 4, "sub-row 4 within line 1"); app.v_offset = logical_top; app.v_sub_offset = sub; // Simulate VHI rebuild with SHRUNK line height (e.g., JSON toggle) // line1 now only 2 visual rows instead of 5 install_vhi(&mut app, &[1, 2, 1]); // Recalibration should clamp sub=4 to new height-1=1 let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1)); let sub = app.v_sub_offset; let first_visual = app.cursor_to_first_visual_row(logical_top); let line_height = app .get_visual_height_index() .map_or(1, |idx| idx.visual_height_of_line(logical_top)); let clamped_sub = sub.min(line_height.saturating_sub(1)); app.v_offset = first_visual.saturating_add(clamped_sub); app.v_sub_offset = 0; // line1 starts at visual row 1, clamped sub=1 → visual row 2 assert_eq!(app.v_offset, 2, "v_offset should be clamped to visual row 2 (line1 row 1 of 2)"); assert_eq!(app.v_sub_offset, 0); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_loading_to_ready_rebases_visual_offset() { let content = "line0\nline1\nline2\nline3\nline4\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; // Install VHI: 2 rows per line install_vhi(&mut app, &[2usize; 5]); // Scroll to visual row 3 → line1 sub-row 1 app.v_offset = 3; app.cursor_line = 1; app.v_sub_offset = 0; // Simulate Loading→Ready invalidation rebase let (new_offset, new_sub) = app.rebase_offset_for_invalidate(); assert_eq!(new_offset, 1, "visual row 3 → line 1"); assert_eq!(new_sub, 1, "sub-row 1 within line 1"); // Apply rebased values and clear VHI (simulate invalidate) app.v_offset = new_offset; app.v_sub_offset = new_sub; if let AppLoadingState::Ready { reader } = &mut app.loading_state { reader.invalidate_visual_height_index(); } // VHI is now None → scroll_down_line uses else branch (v_sub_offset path) // "line1" at width=80 → compute_visual_height=1, sub=1+1 >= 1 → advance app.scroll_down_line(); assert_eq!(app.v_offset, 2, "should advance to line 2 (line1 height=1, sub overflow)"); assert_eq!(app.v_sub_offset, 0); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_compute_line_entry_oversized_raw_guard() { let long_line = "x".repeat(MAX_WRAP_INPUT_LEN + 1); let path = make_temp_file(&format!("short\n{}\n", long_line)); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); assert!(app.is_loaded()); let entry = app.compute_line_entry(1, 80); assert_eq!(entry.visual_height, 1, "oversized line should have height 1"); assert!(entry.level.is_none(), "oversized raw line should skip detect_level"); assert_eq!(entry.wrapped_rows.len(), 1); assert!( entry.wrapped_rows[0].len() <= 80, "preview should be bounded to width" ); let short_entry = app.compute_line_entry(0, 80); assert_eq!(short_entry.visual_height, 1); assert!(short_entry.level.is_none(), "'short' has no log level"); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_compute_visual_height_oversized_raw_guard() { let long_line = "a".repeat(MAX_WRAP_INPUT_LEN + 100); let path = make_temp_file(&format!("hi\n{}\n", long_line)); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); assert!(app.is_loaded()); assert_eq!(app.compute_visual_height(0, 80), 1, "normal line height=1"); assert_eq!(app.compute_visual_height(1, 80), 1, "oversized line height=1"); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_compute_line_entry_post_format_guard() { // Build a JSON object whose raw form is just under MAX_WRAP_INPUT_LEN // but whose pretty-printed form (with added spaces around ':') exceeds it. let inner = "x".repeat(MAX_WRAP_INPUT_LEN - 11); let json_line = format!(r#"{{"msg":"{}"}}"#, inner); assert!( json_line.len() < MAX_WRAP_INPUT_LEN, "raw JSON should be under limit: got {}", json_line.len() ); let path = make_temp_file(&format!("{}\n", json_line)); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.json_format = true; let entry = app.compute_line_entry(0, 80); assert_eq!(entry.visual_height, 1, "post-format oversized should have height 1"); assert_eq!(entry.wrapped_rows.len(), 1); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_truncate_to_columns_basic() { assert_eq!(truncate_to_columns("hello world", 5), "hello"); assert_eq!(truncate_to_columns("hi", 80), "hi"); assert_eq!(truncate_to_columns("", 80), ""); assert_eq!(truncate_to_columns("abc", 0), ""); } #[test] fn test_truncate_to_columns_cjk() { // Each CJK char is 2 columns wide assert_eq!(truncate_to_columns("你好世界", 3), "你"); assert_eq!(truncate_to_columns("你好世界", 4), "你好"); } #[test] fn test_truncate_to_columns_tab() { assert_eq!(truncate_to_columns("a\tb", 3), "a"); } #[test] fn test_truncate_tab_fits_fully() { assert_eq!(truncate_to_columns("a\tb", 5), "a b"); } #[test] fn test_truncate_tab_exact_boundary() { assert_eq!(truncate_to_columns("a\tb", 4), "a "); } #[test] fn test_truncate_cjk_at_boundary() { assert_eq!(truncate_to_columns("你好", 3), "你"); assert_eq!(truncate_to_columns("你好", 4), "你好"); } #[test] fn test_truncate_control_char() { assert_eq!(truncate_to_columns("a\x07b", 5), "a\x07b"); } #[test] fn test_truncate_exact_width() { assert_eq!(truncate_to_columns("abcde", 5), "abcde"); assert_eq!(truncate_to_columns("abcdef", 5), "abcde"); } #[test] fn test_truncate_only_tab() { assert_eq!(truncate_to_columns("\t", 4), " "); assert_eq!(truncate_to_columns("\t", 3), ""); } }