3 Commits

Author SHA1 Message Date
dailz
e99861c76d fix(tui): add MAX_WRAP_INPUT_LEN guard to prevent UI freeze on oversized lines (closes #26)
- compute_line_entry/compute_visual_height: double guard (raw + post-format)
- Skip detect_level on oversized raw input to avoid O(n) JSON parsing
- Add post-format guard for JSON lines that expand beyond 10MB
- progressive_reader: add post-format guard to compute_line_visual_height
- Add truncate_to_columns helper using existing wrap_line_chars
- Fix misleading docstring on MAX_WRAP_INPUT_LEN constant
- Add 6 regression tests covering all guard paths
2026-06-11 13:15:11 +08:00
dailz
a43ef673b0 fix(tui): rebase v_offset before VHI invalidation to prevent viewport jump (closes #25)
Ready state can have VHI=None during async rebuild (Tab toggle, resize,
file append, Loading→Ready transition). Without rebase, v_offset retains
a visual-row value that gets treated as a logical-line offset, causing
viewport jumps and cursor drift.

Changes:
- Add rebase_offset_for_invalidate() to convert v_offset from visual-row
  to logical-line + sub-row before VHI invalidation
- Call it at all 6 invalidation sites in Ready state
- Recalibrate v_offset from viewport top when VHI rebuild completes
- Clamp preserved sub_row to new line height after JSON/width changes

Regression tests: 6 new tests covering rebase conversion, Tab toggle,
VHI rebuild recalibration, sub-row clamping, and Loading→Ready transition.
2026-06-11 09:49:10 +08:00
dailz
70f930eef7 fix(tui): use updated v_offset after params_changed in ensure_viewport_cache (#24)
The loading branch of ensure_viewport_cache captured v_offset before the
params_changed block, which could reassign self.v_offset. This caused the
viewport to use a stale offset when loading + width/format changed together.

Remove the stale local variable and read self.v_offset directly, consistent
with the non-loading branch. Add regression test.
2026-06-11 08:58:10 +08:00
3 changed files with 466 additions and 5 deletions

View File

@@ -223,6 +223,9 @@ pub fn compute_line_visual_height(
} }
if json_format { if json_format {
let formatted = format_json_line(line_text); let formatted = format_json_line(line_text);
if formatted.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
compute_text_visual_height(&formatted, terminal_width) compute_text_visual_height(&formatted, terminal_width)
} else { } else {
compute_text_visual_height(line_text, terminal_width) compute_text_visual_height(line_text, terminal_width)

View File

@@ -1,5 +1,6 @@
/// Maximum input length for wrap/format operations (10 MB). /// Maximum input length for wrap/format operations (10 MB).
/// Lines exceeding this are returned as-is to avoid pathological cases. /// Callers should check against this constant before invoking `wrap_line_chars`
/// to avoid pathological cases on oversized lines.
pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024; pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024;
/// Split a line into chunks of exactly `width` display columns. /// Split a line into chunks of exactly `width` display columns.

View File

@@ -6,7 +6,7 @@ use log_viewer_core::io::progressive_reader::{
IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height, IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height,
spawn_indexer, spawn_indexer,
}; };
use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars}; 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::types::LogLevel;
use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher}; use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher};
@@ -201,12 +201,33 @@ impl App {
/// Compute a single line's viewport entry (wrapped rows + level + height). /// Compute a single line's viewport entry (wrapped rows + level + height).
fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry { fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry {
let raw = self.get_line(line).unwrap_or_default(); 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 level = log_viewer_core::parser::level::detect_level(&raw);
let display_text = if self.json_format { let display_text = if self.json_format {
format_json_line(&raw) format_json_line(&raw)
} else { } else {
raw 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(); let mut wrapped = Vec::new();
for sub_line in display_text.split('\n') { for sub_line in display_text.split('\n') {
wrapped.extend(wrap_line_chars(sub_line, width)); wrapped.extend(wrap_line_chars(sub_line, width));
@@ -222,11 +243,23 @@ impl App {
/// Compute visual height for a single line without storing it. /// Compute visual height for a single line without storing it.
fn compute_visual_height(&self, line: usize, width: usize) -> usize { fn compute_visual_height(&self, line: usize, width: usize) -> usize {
let raw = self.get_line(line).unwrap_or_default(); 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 { let display_text = if self.json_format {
format_json_line(&raw) format_json_line(&raw)
} else { } else {
raw raw
}; };
// Guard 2: post-format expansion.
if display_text.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
let mut height = 0; let mut height = 0;
for sub_line in display_text.split('\n') { for sub_line in display_text.split('\n') {
height += wrap_line_chars(sub_line, width).len(); height += wrap_line_chars(sub_line, width).len();
@@ -246,7 +279,6 @@ impl App {
/// Returns (start_logical, offset_in_line) for rendering. /// Returns (start_logical, offset_in_line) for rendering.
pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) { pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) {
let viewport_height = self.content_height as usize; let viewport_height = self.content_height as usize;
let v_offset = self.v_offset;
if !self.is_loaded() || width == 0 || viewport_height == 0 { if !self.is_loaded() || width == 0 || viewport_height == 0 {
return (0, 0); return (0, 0);
@@ -268,7 +300,7 @@ impl App {
// Find start logical line from v_offset // Find start logical line from v_offset
let (start_logical, offset_in_line) = if self.is_loading() { let (start_logical, offset_in_line) = if self.is_loading() {
(v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset) (self.v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset)
} else { } else {
self.find_logical_line_at_visual_row(self.v_offset, width) self.find_logical_line_at_visual_row(self.v_offset, width)
}; };
@@ -660,12 +692,15 @@ impl App {
self.json_format = !self.json_format; self.json_format = !self.json_format;
self.viewport_cache.invalidate(); self.viewport_cache.invalidate();
let width = self.viewport_cache.width; 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 { if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
if width > 0 { if width > 0 {
reader.start_visual_height_rebuild(width, self.json_format); 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; self.last_g_press = None;
} }
KeyCode::Char('s') | KeyCode::Char('S') KeyCode::Char('s') | KeyCode::Char('S')
@@ -824,6 +859,25 @@ impl App {
} }
} }
/// 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) { fn ensure_visual_height_index(&mut self, width: usize) {
let needs_rebuild = match self.get_visual_height_index() { let needs_rebuild = match self.get_visual_height_index() {
Some(idx) => !idx.is_valid_for(self.json_format, width), Some(idx) => !idx.is_valid_for(self.json_format, width),
@@ -831,10 +885,13 @@ impl App {
}; };
if needs_rebuild { if needs_rebuild {
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state { if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
} }
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
} }
} }
@@ -917,6 +974,7 @@ impl App {
fn handle_file_appended(&mut self) { fn handle_file_appended(&mut self) {
let width = self.get_content_width(); let width = self.get_content_width();
let rebased = self.rebase_offset_for_invalidate();
match &mut self.loading_state { match &mut self.loading_state {
AppLoadingState::Ready { reader } => { AppLoadingState::Ready { reader } => {
let old_reader_line_count = reader.line_count(); let old_reader_line_count = reader.line_count();
@@ -967,6 +1025,9 @@ impl App {
index.extend_from_heights(&new_heights); index.extend_from_heights(&new_heights);
} }
} else { } else {
let (new_offset, new_sub) = rebased;
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
} }
@@ -975,9 +1036,11 @@ impl App {
} }
Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => { Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => {
let _ = reader.save_cache(); let _ = reader.save_cache();
let (new_offset, _new_sub) = rebased;
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1)); self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
self.v_offset = new_offset;
self.v_sub_offset = 0; self.v_sub_offset = 0;
self.viewport_cache.invalidate(); self.viewport_cache.invalidate();
self.clamp_v_offset(); self.clamp_v_offset();
@@ -994,12 +1057,14 @@ impl App {
fn reload_ready_reader(&mut self) { fn reload_ready_reader(&mut self) {
let width = self.get_content_width(); 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 { if let AppLoadingState::Ready { reader } = &mut self.loading_state {
let _ = reader.reload(); let _ = reader.reload();
let _ = reader.save_cache(); let _ = reader.save_cache();
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1)); self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
self.v_offset = new_offset;
self.v_sub_offset = 0; self.v_sub_offset = 0;
self.viewport_cache.invalidate(); self.viewport_cache.invalidate();
self.clamp_v_offset(); self.clamp_v_offset();
@@ -1031,10 +1096,23 @@ impl App {
} = &mut reader.state } = &mut reader.state
{ {
*visual_height_index = Some(index); *visual_height_index = Some(index);
self.viewport_cache.invalidate();
} }
} }
} }
// 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) // Poll main indexer (Loading state)
let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty); let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty);
@@ -1079,9 +1157,12 @@ impl App {
// Gutter width changes (~N → N) shift content_width, so any // Gutter width changes (~N → N) shift content_width, so any
// VisualHeightIndex built with the old width is stale. // 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 { if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
} }
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
if self.reload_after_loading { if self.reload_after_loading {
self.reload_after_loading = false; self.reload_after_loading = false;
@@ -1106,6 +1187,13 @@ impl App {
} }
} }
fn truncate_to_columns(s: &str, max_cols: usize) -> String {
if max_cols == 0 || s.is_empty() {
return String::new();
}
wrap_line_chars(s, max_cols).into_iter().next().unwrap_or_default()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -2538,6 +2626,42 @@ plain text line
assert!(result.is_ok()); 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]) { fn install_vhi(app: &mut App, heights: &[usize]) {
let width = app.get_content_width(); let width = app.get_content_width();
let json_format = app.json_format; let json_format = app.json_format;
@@ -3091,4 +3215,337 @@ plain text line
}); });
assert!(result.is_ok()); 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() {
// Tab expands to 4 spaces
assert_eq!(truncate_to_columns("a\tb", 3), "a");
}
} }