use super::*;
#[test]
fn panel_auto_min_cols_matches_gutter_threshold() {
let indent_at = |cols: u16| -> usize {
let band = (cols.saturating_sub(2)) as usize;
let target = band.min(120);
band.saturating_sub(target) / 2
};
assert!(
indent_at(PANEL_AUTO_MIN_COLS) >= 15,
"at the threshold ({PANEL_AUTO_MIN_COLS}) a panel must fit (indent {} >= 15)",
indent_at(PANEL_AUTO_MIN_COLS),
);
assert!(
indent_at(PANEL_AUTO_MIN_COLS - 1) < 15,
"one column below the threshold a panel must NOT fit (indent {} < 15)",
indent_at(PANEL_AUTO_MIN_COLS - 1),
);
}
#[test]
fn parse_display_spec_full_layout() {
let v = parse_display_spec("left|main|right").unwrap();
assert!(v.left && v.right);
}
#[test]
fn parse_display_spec_main_only_hides_both_sides() {
let v = parse_display_spec("main").unwrap();
assert!(!v.left && !v.right);
}
#[test]
fn parse_display_spec_main_and_right() {
let v = parse_display_spec("main|right").unwrap();
assert!(!v.left && v.right);
}
#[test]
fn parse_display_spec_left_only() {
let v = parse_display_spec("left").unwrap();
assert!(v.left && !v.right);
}
#[test]
fn parse_display_spec_accepts_whitespace_commas_and_case() {
let v = parse_display_spec("RIGHT, Left").unwrap();
assert!(v.left && v.right);
let v = parse_display_spec("main right").unwrap();
assert!(!v.left && v.right);
}
#[test]
fn parse_display_spec_rejects_empty_and_unknown() {
assert!(parse_display_spec("").is_err());
assert!(parse_display_spec(" ").is_err());
let err = parse_display_spec("middle").unwrap_err();
assert!(
err.contains("middle"),
"error should name the bad token: {err}"
);
}
#[test]
fn wrap_editor_empty() {
let (rows, r, c) = wrap_editor("", 0, 80);
assert_eq!(rows, vec![String::new()]);
assert_eq!((r, c), (0, 0));
}
#[test]
fn wrap_editor_no_wrap_short() {
let (rows, r, c) = wrap_editor("hello", 5, 80);
assert_eq!(rows, vec!["hello".to_string()]);
assert_eq!((r, c), (0, 5));
}
#[test]
fn wrap_editor_newlines_split() {
let (rows, r, c) = wrap_editor("a\nb\ncc", 5, 80);
assert_eq!(
rows,
vec!["a".to_string(), "b".to_string(), "cc".to_string()]
);
assert_eq!((r, c), (2, 1));
}
#[test]
fn wrap_editor_soft_wrap() {
let s = "abcdefghij"; let (rows, r, c) = wrap_editor(s, 10, 4);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0], "abcd");
assert_eq!(rows[1], "efgh");
assert_eq!(rows[2], "ij");
assert_eq!((r, c), (2, 2));
}
#[test]
fn wrap_editor_long_buffer_tail_on_last_row() {
use unicode_width::UnicodeWidthStr;
let s = "word ".repeat(60); let wrap_w = 20;
let (rows, cursor_row, _) = wrap_editor(&s, s.len(), wrap_w);
assert!(rows.len() > 1, "long buffer must wrap to multiple rows");
for row in &rows {
assert!(
UnicodeWidthStr::width(row.as_str()) <= wrap_w,
"each row must fit wrap_w; got {:?} ({} cells)",
row,
UnicodeWidthStr::width(row.as_str())
);
}
assert_eq!(
cursor_row as usize,
rows.len() - 1,
"cursor at end of buffer must land on the last (visible) row"
);
}
#[test]
fn editor_scroll_offset_keeps_cursor_visible() {
assert_eq!(editor_scroll_offset(5, 4, 8), 0);
assert_eq!(editor_scroll_offset(8, 7, 8), 0);
assert_eq!(editor_scroll_offset(20, 19, 8), 12); assert_eq!(editor_scroll_offset(20, 10, 8), 3); assert_eq!(editor_scroll_offset(20, 5, 8), 0);
assert_eq!(editor_scroll_offset(10, 9, 8), 2); assert_eq!(editor_scroll_offset(10, 9, 0), 0);
assert_eq!(editor_scroll_offset(0, 0, 8), 0);
}
#[test]
fn chat_snapshot_save_load_roundtrip() {
let mut r = Renderer::new().expect("renderer");
assert_eq!(r.active_chat(), 0);
assert_eq!(r.chat_count(), 1);
assert_eq!(r.chat_names(), vec!["main".to_string()]);
r.buffer.push(LineEntry {
text: CompactString::new("main-line-1"),
color: Color::White,
});
r.scroll_offset = 5;
let sub_idx = r.add_chat("subagent-1");
assert_eq!(sub_idx, 1);
assert_eq!(r.chat_count(), 2);
r.switch_chat(sub_idx);
assert_eq!(r.active_chat(), 1);
assert!(r.buffer.is_empty());
assert_eq!(r.scroll_offset, 0);
r.buffer.push(LineEntry {
text: CompactString::new("sub-line-1"),
color: Color::Cyan,
});
r.scroll_offset = 2;
r.switch_chat(0);
assert_eq!(r.buffer.len(), 1);
assert_eq!(r.buffer[0].text.as_str(), "main-line-1");
assert_eq!(r.scroll_offset, 5);
r.switch_chat(1);
assert_eq!(r.buffer.len(), 1);
assert_eq!(r.buffer[0].text.as_str(), "sub-line-1");
assert_eq!(r.scroll_offset, 2);
r.switch_chat(1);
assert_eq!(r.buffer.len(), 1);
r.switch_chat(99);
assert_eq!(r.active_chat(), 1);
}
#[test]
fn next_chat_cycles_forward_with_wrap() {
let mut r = Renderer::new().expect("renderer");
r.add_chat("one");
r.add_chat("two");
assert_eq!(r.chat_count(), 3); assert_eq!(r.active_chat(), 0);
r.next_chat();
assert_eq!(r.active_chat(), 1);
r.next_chat();
assert_eq!(r.active_chat(), 2);
r.next_chat(); assert_eq!(r.active_chat(), 0);
}
#[test]
fn prev_chat_cycles_backward_with_wrap() {
let mut r = Renderer::new().expect("renderer");
r.add_chat("one");
r.add_chat("two");
assert_eq!(r.chat_count(), 3);
r.prev_chat();
assert_eq!(r.active_chat(), 2);
r.prev_chat();
assert_eq!(r.active_chat(), 1);
r.prev_chat();
assert_eq!(r.active_chat(), 0);
}
#[test]
fn next_prev_noop_with_single_chat() {
let mut r = Renderer::new().expect("renderer");
assert_eq!(r.chat_count(), 1);
r.next_chat();
assert_eq!(r.active_chat(), 0);
r.prev_chat();
assert_eq!(r.active_chat(), 0);
}
#[test]
fn remove_chat_adjusts_active() {
let mut r = Renderer::new().expect("renderer");
r.add_chat("one");
r.add_chat("two");
r.add_chat("three");
r.switch_chat(2); assert_eq!(r.active_chat(), 2);
r.remove_chat(1);
assert_eq!(r.chat_count(), 3);
assert_eq!(r.active_chat(), 1); r.switch_chat(2); r.remove_chat(2);
assert_eq!(r.active_chat(), 0);
let mut r2 = Renderer::new().expect("renderer");
r2.remove_chat(0);
assert_eq!(r2.chat_count(), 1);
assert_eq!(r2.active_chat(), 0);
}
fn fresh_with_lines_scrollable(n: usize, min_scroll_margin: usize) -> Renderer {
let mut r = Renderer::new().expect("renderer");
let visible = r.visible_lines();
let need = (visible + min_scroll_margin).max(n);
for i in 0..need {
r.buffer.push(LineEntry {
text: CompactString::new(&format!("line {i}")),
color: Color::White,
});
}
r.lines = r.buffer.len() as u16;
r
}
fn fresh_with_lines(n: usize) -> Renderer {
fresh_with_lines_scrollable(n, 15)
}
fn view_start(r: &Renderer) -> usize {
let visible = r.visible_lines();
let total = r.buffer.len();
let start = if r.scroll_offset == 0 {
total.saturating_sub(visible)
} else {
total.saturating_sub(r.scroll_offset + visible)
};
start.min(total.saturating_sub(visible))
}
#[test]
fn regression_scrolled_up_view_stays_anchored_through_appends() {
let mut r = fresh_with_lines(50);
for _ in 0..10 {
r.scroll_line_up();
}
let pinned_start = view_start(&r);
for i in 0..8 {
r.push_buffer_line(LineEntry {
text: CompactString::new(&format!("new {i}")),
color: Color::White,
});
}
assert_eq!(view_start(&r), pinned_start);
}
#[test]
fn regression_replace_from_keeps_view_anchored_when_scrolled_up() {
let mut r = fresh_with_lines_scrollable(50, 15);
for _ in 0..10 {
r.scroll_line_up();
}
let pinned_start = view_start(&r);
let total = r.buffer.len();
let repl_start = total.saturating_sub(10);
let new_lines: Vec<LineEntry> = (0..20)
.map(|i| LineEntry {
text: CompactString::new(&format!("repl {i}")),
color: Color::White,
})
.collect();
r.replace_from(repl_start, new_lines);
assert_eq!(
view_start(&r),
pinned_start,
"view drifted after replace-with-more"
);
let total = r.buffer.len();
let repl_start = total.saturating_sub(8);
let shorter: Vec<LineEntry> = (0..3)
.map(|i| LineEntry {
text: CompactString::new(&format!("sh {i}")),
color: Color::White,
})
.collect();
r.replace_from(repl_start, shorter);
let after = view_start(&r);
assert!(
after <= pinned_start,
"view drifted upward: after={after} pinned_start={pinned_start}",
);
}
#[test]
fn at_bottom_view_follows_new_content() {
let mut r = fresh_with_lines(50);
assert_eq!(r.scroll_offset, 0);
for i in 0..5 {
r.push_buffer_line(LineEntry {
text: CompactString::new(&format!("new {i}")),
color: Color::White,
});
}
assert_eq!(r.scroll_offset, 0, "bottom-anchored view must stay at 0");
let visible = r.visible_lines();
let total = r.buffer.len();
assert_eq!(view_start(&r), total.saturating_sub(visible));
}
#[test]
fn selection_indices_stay_absolute_under_streaming_appends() {
let mut r = fresh_with_lines(50);
for _ in 0..10 {
r.scroll_line_up();
}
r.selection_active = true;
r.selection_start = Some((15, 0));
r.selection_end = Some((20, 5));
for i in 0..7 {
r.push_buffer_line(LineEntry {
text: CompactString::new(&format!("new {i}")),
color: Color::White,
});
}
assert_eq!(r.selection_start, Some((15, 0)));
assert_eq!(r.selection_end, Some((20, 5)));
}
#[test]
fn push_clamps_scroll_offset_to_max_when_buffer_grows() {
let mut r = fresh_with_lines(2);
let visible = r.visible_lines();
r.scroll_offset = 100;
for _ in 0..3 {
r.push_buffer_line(LineEntry {
text: CompactString::new("more"),
color: Color::White,
});
}
let max_offset = r.buffer.len().saturating_sub(visible);
assert!(
r.scroll_offset <= max_offset,
"scroll_offset {} must be ≤ max {}",
r.scroll_offset,
max_offset
);
}
#[test]
fn commit_partial_routes_through_anchor_aware_push() {
let mut r = fresh_with_lines(50);
for _ in 0..10 {
r.scroll_line_up();
}
let pinned_start = view_start(&r);
r.partial = CompactString::new("a streamed token chunk");
r.partial_color = Color::White;
r.commit_partial();
assert_eq!(view_start(&r), pinned_start);
}
fn fresh_with_text(lines: &[&str]) -> Renderer {
let mut r = Renderer::new().unwrap();
for s in lines {
r.buffer.push(LineEntry {
text: CompactString::new(s),
color: Color::White,
});
}
r
}
#[test]
fn selected_text_single_row_substring() {
let mut r = fresh_with_text(&["hello world"]);
r.selection_active = true;
r.selection_start = Some((0, 6));
r.selection_end = Some((0, 11));
assert_eq!(r.selected_text(), Some("world".to_string()));
}
#[test]
fn selected_text_reverse_drag_normalizes() {
let mut r = fresh_with_text(&["hello world"]);
r.selection_active = true;
r.selection_start = Some((0, 11));
r.selection_end = Some((0, 6));
assert_eq!(r.selected_text(), Some("world".to_string()));
}
#[test]
fn selected_text_multi_row_spans_lines() {
let mut r = fresh_with_text(&["first line", "middle", "last line"]);
r.selection_active = true;
r.selection_start = Some((0, 6)); r.selection_end = Some((2, 4)); assert_eq!(r.selected_text(), Some("line\nmiddle\nlast".to_string()));
}
#[test]
fn selected_text_empty_selection_returns_none() {
let mut r = fresh_with_text(&["hello"]);
r.selection_active = true;
r.selection_start = Some((0, 3));
r.selection_end = Some((0, 3));
assert!(r.selected_text().is_none());
}
#[test]
fn selected_text_handles_unicode() {
let mut r = fresh_with_text(&["café 🦀 rust"]);
r.selection_active = true;
r.selection_start = Some((0, 0));
r.selection_end = Some((0, 6)); assert_eq!(r.selected_text(), Some("café 🦀".to_string()));
}
#[test]
fn selected_text_strips_ansi_escapes() {
let mut r = fresh_with_text(&[]);
r.buffer.clear();
r.buffer.push(LineEntry {
text: CompactString::from("hello \x1b[31mred\x1b[0m world"),
color: Color::Reset,
});
r.selection_active = true;
r.selection_start = Some((0, 0));
r.selection_end = Some((0, 15));
assert_eq!(r.selected_text(), Some("hello red world".to_string()));
r.selection_end = Some((0, 15));
r.selection_start = Some((0, 6));
assert_eq!(r.selected_text(), Some("red world".to_string()));
}
#[test]
fn selected_text_joins_soft_wrapped_rows() {
let mut r = fresh_with_text(&["the quick ", "brown fox ", "jumps"]);
r.selection_active = true;
r.selection_start = Some((0, 0));
r.selection_end = Some((2, 5));
assert_eq!(
r.selected_text(),
Some("the quick brown fox jumps".to_string())
);
}
#[test]
fn selected_text_keeps_hard_newlines_and_blanks() {
let mut r = fresh_with_text(&["para one ", "wraps here", "", "next para"]);
r.selection_active = true;
r.selection_start = Some((0, 0));
r.selection_end = Some((3, 9));
assert_eq!(
r.selected_text(),
Some("para one wraps here\n\nnext para".to_string())
);
}
#[test]
fn selected_text_joins_real_wrapped_markdown() {
let prose = "the quick brown fox jumps over the lazy dog again and again";
let mut styled = crate::ui::markdown::markdown_to_styled(prose, 20, Color::White);
while styled
.last()
.is_some_and(|e| crate::ui::ansi::strip_ansi(&e.text).trim().is_empty())
{
styled.pop();
}
assert!(styled.len() > 1, "prose should wrap to multiple rows");
let last = styled.len() - 1;
let last_len = crate::ui::ansi::strip_ansi(&styled[last].text)
.chars()
.count();
let mut r = Renderer::new().unwrap();
r.buffer.clear();
for e in styled {
r.buffer.push(e);
}
r.selection_active = true;
r.selection_start = Some((0, 0));
r.selection_end = Some((last, last_len));
assert_eq!(r.selected_text(), Some(prose.to_string()));
}
#[test]
fn selected_text_join_respects_partial_rows() {
let mut r = fresh_with_text(&["xxthe quick ", "brown foxyy"]);
r.selection_active = true;
r.selection_start = Some((0, 2)); r.selection_end = Some((1, 9)); assert_eq!(r.selected_text(), Some("the quick brown fox".to_string()));
}
#[test]
fn buffer_pos_at_clamps_past_eol() {
let r = fresh_with_text(&["short"]);
let pos = r.buffer_pos_at(1, 999);
assert_eq!(pos, Some((0, 5)));
}
#[test]
fn display_col_to_char_index_ascii_round_trip() {
assert_eq!(display_col_to_char_index("hello", 0), 0);
assert_eq!(display_col_to_char_index("hello", 3), 3);
assert_eq!(display_col_to_char_index("hello", 5), 5);
assert_eq!(display_col_to_char_index("hello", 99), 5);
}
#[test]
fn display_col_to_char_index_cjk_compresses() {
let s = "日本";
assert_eq!(display_col_to_char_index(s, 0), 0);
assert_eq!(display_col_to_char_index(s, 1), 0);
assert_eq!(display_col_to_char_index(s, 2), 1); assert_eq!(display_col_to_char_index(s, 3), 1); assert_eq!(display_col_to_char_index(s, 4), 2); assert_eq!(display_col_to_char_index(s, 99), 2);
}
#[test]
fn display_col_to_char_index_emoji() {
let s = "a🦀b";
assert_eq!(display_col_to_char_index(s, 0), 0); assert_eq!(display_col_to_char_index(s, 1), 1); assert_eq!(display_col_to_char_index(s, 2), 1); assert_eq!(display_col_to_char_index(s, 3), 2); assert_eq!(display_col_to_char_index(s, 4), 3); }
#[test]
fn buffer_pos_at_clamps_to_visible_chars_not_raw_bytes() {
let mut r = fresh_with_text(&[]);
r.buffer.clear();
r.buffer.push(LineEntry {
text: CompactString::from("hello \x1b[31mred\x1b[0m world"),
color: Color::Reset,
});
let pos = r.buffer_pos_at(1, 999).expect("must resolve");
assert_eq!(pos.1, 15, "clamp should hit visible length 15, not raw 25");
}
fn lines(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn wrap_empty_buffer_has_one_row() {
let (rows, cr, cc) = wrap_input(&lines(&[""]), 0, 0, 10);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].logical_line, 0);
assert_eq!((rows[0].char_start, rows[0].char_end), (0, 0));
assert_eq!((cr, cc), (0, 0));
}
#[test]
fn wrap_short_line_no_split() {
let (rows, cr, cc) = wrap_input(&lines(&["hi"]), 0, 2, 10);
assert_eq!(rows.len(), 1);
assert_eq!((rows[0].char_start, rows[0].char_end), (0, 2));
assert_eq!((cr, cc), (0, 2));
}
#[test]
fn wrap_splits_long_line_into_multiple_visual_rows() {
let (rows, cr, cc) = wrap_input(&lines(&["abcdefghi"]), 0, 5, 3);
assert_eq!(rows.len(), 3);
assert_eq!((rows[0].char_start, rows[0].char_end), (0, 3));
assert_eq!((rows[1].char_start, rows[1].char_end), (3, 6));
assert_eq!((rows[2].char_start, rows[2].char_end), (6, 9));
assert_eq!((cr, cc), (1, 2));
}
#[test]
fn wrap_cursor_at_exact_boundary_stays_on_filled_row() {
let (rows, cr, cc) = wrap_input(&lines(&["abc"]), 0, 3, 3);
assert_eq!(rows.len(), 1);
assert_eq!((cr, cc), (0, 3));
}
#[test]
fn wrap_cursor_after_full_row_with_continuation() {
let (rows, cr, cc) = wrap_input(&lines(&["abcdef"]), 0, 6, 3);
assert_eq!(rows.len(), 2);
assert_eq!((cr, cc), (1, 3));
}
#[test]
fn wrap_cursor_at_start_of_continuation_row() {
let (rows, cr, cc) = wrap_input(&lines(&["abcdef"]), 0, 3, 3);
assert_eq!(rows.len(), 2);
assert_eq!((cr, cc), (1, 0));
}
#[test]
fn wrap_multiple_logical_lines() {
let (rows, cr, cc) = wrap_input(&lines(&["abc", "defgh"]), 1, 4, 3);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].logical_line, 0);
assert_eq!(rows[1].logical_line, 1);
assert_eq!(rows[2].logical_line, 1);
assert_eq!((cr, cc), (2, 1));
}
#[test]
fn wrap_empty_then_filled_line_cursor_on_empty() {
let (rows, cr, cc) = wrap_input(&lines(&["", "abc"]), 0, 0, 3);
assert_eq!(rows.len(), 2);
assert_eq!((rows[0].char_start, rows[0].char_end), (0, 0));
assert_eq!((rows[1].char_start, rows[1].char_end), (0, 3));
assert_eq!((cr, cc), (0, 0));
}
#[test]
fn wrap_width_one_degenerate() {
let (rows, cr, cc) = wrap_input(&lines(&["abc"]), 0, 2, 1);
assert_eq!(rows.len(), 3);
assert_eq!((cr, cc), (2, 0));
}
#[cfg(feature = "experimental-ui-terminal-tab")]
#[test]
fn terminal_title_idle_and_done_show_simple_title() {
use crate::ui::avatar::AvatarState;
let t = super::format_terminal_title(AvatarState::Idle, None);
assert_eq!(t, "● dirge");
let t = super::format_terminal_title(AvatarState::Done, Some("bash"));
assert_eq!(t, "● dirge");
}
#[cfg(feature = "experimental-ui-terminal-tab")]
#[test]
fn terminal_title_shows_tool_name_for_working_states() {
use crate::ui::avatar::AvatarState;
let t = super::format_terminal_title(AvatarState::Reading, Some("grep"));
assert!(t.contains("grep"), "title should contain tool name: {t:?}");
assert!(
t.contains("◌"),
"working states should use yellow dot marker: {t:?}"
);
let t = super::format_terminal_title(AvatarState::Writing, Some("edit"));
assert!(t.contains("edit"), "title should contain tool name: {t:?}");
let t = super::format_terminal_title(AvatarState::Bash, Some("bash"));
assert!(t.contains("bash"), "title should contain tool name: {t:?}");
}
#[cfg(feature = "experimental-ui-terminal-tab")]
#[test]
fn terminal_title_error_and_alert_show_warning_marker() {
use crate::ui::avatar::AvatarState;
let t = super::format_terminal_title(AvatarState::Error, None);
assert!(t.contains("ERROR"));
assert!(
t.contains("✗"),
"error states should use red dot marker: {t:?}"
);
let t = super::format_terminal_title(AvatarState::Alert, None);
assert!(t.contains("needs input"));
assert!(
t.contains("✗"),
"alert states should use red dot marker: {t:?}"
);
}
#[cfg(feature = "experimental-ui-terminal-tab")]
#[test]
fn terminal_title_strips_control_bytes_from_tool_name() {
use crate::ui::avatar::AvatarState;
let t = super::format_terminal_title(AvatarState::Reading, Some("evil\x07\x1b[31m"));
assert!(!t.contains('\x07'));
assert!(!t.contains('\x1b'));
assert!(t.contains("evil"));
}
#[cfg(feature = "experimental-ui-terminal-tab")]
#[test]
fn terminal_title_all_control_bytes_falls_back_to_working() {
use crate::ui::avatar::AvatarState;
let t = super::format_terminal_title(AvatarState::Bash, Some("\x07\x1b\n"));
assert_eq!(t, "◌ dirge: working");
}
#[cfg(feature = "experimental-ui-terminal-tab")]
#[test]
fn osc_set_title_uses_st_terminator() {
let bytes = super::osc_set_title("hello");
assert_eq!(bytes, b"\x1b]0;hello\x1b\\");
assert!(
!bytes.contains(&0x07),
"BEL should not be used: {:?}",
bytes
);
}
#[cfg(feature = "experimental-ui-terminal-tab")]
#[test]
fn osc_reset_title_releases_to_shell() {
let bytes = super::osc_reset_title();
assert_eq!(bytes, b"\x1b]0;\x1b\\");
}
fn panel_with_modified(n: usize) -> PanelData {
PanelData {
modified: (0..n).map(|i| format!("f{i}.rs")).collect(),
..PanelData::default()
}
}
#[test]
fn modified_offset_clamps_to_list_size() {
let mut r = Renderer::new().unwrap();
r.set_panel_data(panel_with_modified(20));
r.panel_modified_scroll(100, 5);
assert_eq!(r.modified_offset, 15);
let changed = r.panel_modified_scroll(10, 5);
assert!(!changed);
assert_eq!(r.modified_offset, 15);
r.panel_modified_scroll(-1000, 5);
assert_eq!(r.modified_offset, 0);
}
#[test]
fn modified_offset_resets_on_new_entry() {
let mut r = Renderer::new().unwrap();
r.set_panel_data(panel_with_modified(20));
r.panel_modified_scroll(7, 5);
assert_eq!(r.modified_offset, 7);
r.set_panel_data(panel_with_modified(21));
assert_eq!(r.modified_offset, 0);
}
#[test]
fn modified_offset_persists_on_shrink() {
let mut r = Renderer::new().unwrap();
r.set_panel_data(panel_with_modified(20));
r.panel_modified_scroll(7, 5);
assert_eq!(r.modified_offset, 7);
r.set_panel_data(panel_with_modified(15));
assert_eq!(r.modified_offset, 7);
}
#[test]
fn modified_offset_no_op_when_list_fits() {
let mut r = Renderer::new().unwrap();
r.set_panel_data(panel_with_modified(3));
let changed = r.panel_modified_scroll(5, 5);
assert!(!changed);
assert_eq!(r.modified_offset, 0);
}
#[test]
fn footer_shows_both_directions_when_scrolled() {
use crate::ui::tui::layout::Layout;
use crate::ui::tui::panels::RightPanel;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let data = panel_with_modified(20);
let layout = Layout::new(160, 30, 1);
let scan_footer = |offset: usize| -> String {
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
terminal
.draw(|f| {
f.render_widget(
RightPanel::new(&data).modified_offset(offset),
layout.right_panel,
);
})
.unwrap();
backend = terminal.backend().clone();
let mut rows: Vec<String> = Vec::new();
for y in layout.right_panel.y..(layout.right_panel.y + layout.right_panel.height) {
let row: String = (layout.right_panel.x
..layout.right_panel.x + layout.right_panel.width)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect();
rows.push(row);
}
rows.join("\n")
};
let head = scan_footer(0);
assert!(
head.contains("older"),
"default-view footer should still read '+N older'; got:\n{head}"
);
assert!(
!head.contains("newer"),
"default-view footer should NOT mention 'newer'; got:\n{head}"
);
let scrolled = scan_footer(5);
assert!(
scrolled.contains("↑") && scrolled.contains("newer") && scrolled.contains("↓"),
"scrolled footer should mention BOTH directions; got:\n{scrolled}"
);
}
#[test]
fn mode_reassert_payload_re_enables_mouse_and_paste() {
let now = std::time::Instant::now();
let bytes = super::mode_reassert_payload(None, now).expect("first paint always re-asserts");
let s = std::str::from_utf8(bytes).unwrap();
assert!(
s.contains("\x1b[?1000h"),
"must re-enable basic mouse tracking"
);
assert!(
s.contains("\x1b[?1006h"),
"must re-enable SGR mouse encoding"
);
assert!(s.contains("\x1b[?2004h"), "must re-enable bracketed paste");
assert!(!s.contains("\x1b[?1049h"), "must not re-enter alt screen");
assert!(!s.contains("\x1b[?25"), "must not touch cursor visibility");
}
#[test]
fn mode_reassert_payload_is_throttled() {
let t0 = std::time::Instant::now();
assert!(
super::mode_reassert_payload(None, t0).is_some(),
"first paint (no prior assert) re-asserts"
);
assert!(
super::mode_reassert_payload(Some(t0), t0).is_none(),
"same instant → not due"
);
assert!(
super::mode_reassert_payload(Some(t0), t0 + std::time::Duration::from_millis(100))
.is_none(),
"100ms later → still throttled"
);
assert!(
super::mode_reassert_payload(Some(t0), t0 + super::MODE_REASSERT_INTERVAL).is_some(),
"after the interval → re-asserts (self-heal)"
);
}
#[test]
fn eviction_generation_bumps_when_scrollback_overflows() {
let mut r = Renderer::new().expect("renderer");
assert_eq!(r.eviction_generation(), 0);
for i in 0..20_050 {
let _ = r.write_line(&format!("l{i}"), Color::White);
}
assert!(
r.eviction_generation() >= 1,
"front eviction must bump the generation"
);
}