use super::*;
use crate::tui::{PickerAction, PickerItem, UiPicker};
use pretty_assertions::assert_eq;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn display_width_ignores_control_characters_filtered_by_ratatui() {
assert_eq!(display_width("left\tright"), 9);
assert_eq!(display_width("left\rright"), 9);
assert_eq!(display_width("left\u{1b}right"), 9);
}
#[test]
fn truncate_one_line_matches_expected_outputs() {
let cases = [
("zero width", "abc", 0, ""),
("width one", "abc", 1, "…"),
("empty", "", 4, ""),
("ASCII exact fit", "abc", 3, "abc"),
("ASCII truncation", "abcdef", 4, "abc…"),
("wide exact fit", "界a", 3, "界a"),
("wide truncation", "界ab", 3, "界…"),
("combining exact fit", "e\u{301}x", 2, "e\u{301}x"),
("combining truncation", "e\u{301}xy", 2, "e\u{301}…"),
("one newline", "ab\ncd", 5, "ab cd"),
("multiple newlines", "a\n\nbc", 4, "a …"),
];
for (name, text, width, expected) in cases {
assert_eq!(truncate_one_line(text, width), expected, "{name}");
}
}
#[test]
fn truncate_keep_end_matches_expected_outputs() {
let cases = [
("zero width", "abc", 0, ""),
("width one", "abc", 1, "…"),
("empty", "", 4, ""),
("ASCII exact fit", "abc", 3, "abc"),
("ASCII truncation", "abcdef", 4, "…def"),
("wide exact fit", "a界", 3, "a界"),
("wide truncation", "ab界", 3, "…界"),
("combining exact fit", "be\u{301}", 2, "be\u{301}"),
("combining truncation", "xabe\u{301}", 3, "…be\u{301}"),
("one newline", "ab\ncd", 5, "ab cd"),
("multiple newlines", "a\n\nbc", 4, "… bc"),
];
for (name, text, width, expected) in cases {
assert_eq!(truncate_keep_end(text, width), expected, "{name}");
}
}
#[test]
fn complete_visual_prefix_preserves_trailing_newline_state() {
assert_eq!(complete_visual_prefix_byte_index("a\n", 10), "a\n".len());
assert_eq!(
complete_visual_prefix_byte_index("a\n\n", 10),
"a\n\n".len()
);
assert_eq!(complete_visual_prefix_byte_index("a\nb", 10), "a\n".len());
}
#[test]
fn complete_visual_prefix_keeps_multibyte_boundaries() {
assert_eq!(complete_visual_prefix_byte_index("éa", 2), "éa".len());
assert_eq!(complete_visual_prefix_byte_index("éab", 2), "éa".len());
}
#[test]
fn complete_visual_prefix_wraps_at_exact_width() {
assert_eq!(complete_visual_prefix_byte_index("abc", 3), 3);
assert_eq!(complete_visual_prefix_byte_index("abcd", 3), 3);
assert_eq!(complete_visual_prefix_byte_index("abcdef", 3), 6);
}
#[test]
fn wrapped_text_prefers_whitespace_boundaries() {
let cases = [
("hello wide world", 10, vec!["hello wide", "world"]),
("models.dev mapping.", 10, vec!["models.dev", "mapping."]),
("hello world", 6, vec!["hello ", "world"]),
];
for (text, width, expected) in cases {
let mut lines = Vec::new();
push_wrapped_text(&mut lines, text, width, Style::default(), LineFill::Natural);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert_eq!(
rendered,
expected
.iter()
.map(|part| (*part).to_string())
.collect::<Vec<_>>(),
"text {text:?} width {width}"
);
}
}
#[test]
fn complete_visual_prefix_prefers_whitespace_boundaries() {
assert_eq!(
complete_visual_prefix_byte_index("hello wide", 8),
"hello ".len()
);
assert_eq!(
complete_visual_prefix_byte_index("hello wide", 10),
"hello wide".len()
);
}
#[test]
fn wrapped_text_preserves_leading_repeated_and_trailing_whitespace() {
let mut lines = Vec::new();
push_wrapped_text(
&mut lines,
" indented\na b\ntrail ",
20,
Style::default(),
LineFill::Natural,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert_eq!(
rendered,
vec![
" indented".to_string(),
"a b".to_string(),
"trail ".to_string()
]
);
}
#[test]
fn wrapped_text_preserves_tabs_and_whitespace_only_lines() {
let mut lines = Vec::new();
push_wrapped_text(
&mut lines,
"\tindented\n ",
20,
Style::default(),
LineFill::Natural,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert_eq!(rendered, vec!["\tindented".to_string(), " ".to_string()]);
}
#[test]
fn wrapped_text_preserves_whitespace_when_breaking_at_boundary() {
let mut lines = Vec::new();
push_wrapped_text(
&mut lines,
"hello wide",
8,
Style::default(),
LineFill::Natural,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert_eq!(rendered, vec!["hello ".to_string(), "wide".to_string()]);
}
#[test]
fn complete_visual_prefix_and_rendering_agree_on_whitespace_boundary() {
let text = "hello wide";
let split = complete_visual_prefix_byte_index(text, 8);
let mut lines = Vec::new();
push_wrapped_text(&mut lines, text, 8, Style::default(), LineFill::Natural);
assert_eq!(&text[..split], "hello ");
assert_eq!(line_text(&lines[0]), "hello ");
}
#[test]
fn complete_visual_prefix_and_rendering_agree_on_exact_width_trailing_space() {
let text = "abc ";
let split = complete_visual_prefix_byte_index(text, 3);
let mut lines = Vec::new();
push_wrapped_text(&mut lines, text, 3, Style::default(), LineFill::Natural);
assert_eq!(&text[..split], "abc");
assert_eq!(
lines.iter().map(line_text).collect::<Vec<_>>(),
vec!["abc".to_string()]
);
}
#[test]
fn wrapped_text_handles_wide_chars_in_narrow_width() {
let mut lines = Vec::new();
push_wrapped_text(&mut lines, "ä½ a", 1, Style::default(), LineFill::Natural);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert_eq!(rendered, vec!["ä½ ".to_string(), "a".to_string()]);
}
#[test]
fn wrapped_text_keeps_list_syntax_out_of_generic_wrapping() {
assert_eq!(
wrap_line_at_whitespace(
"- fixtures/downstream/no-default-features/Cargo.toml: package 0.0.0",
39,
),
vec![
"- ",
"fixtures/downstream/no-default-features",
"/Cargo.toml: package 0.0.0",
]
);
}
#[test]
fn long_words_still_hard_wrap() {
let mut lines = Vec::new();
push_wrapped_text(
&mut lines,
"abcdefghijk",
5,
Style::default(),
LineFill::Natural,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert_eq!(
rendered,
vec!["abcde".to_string(), "fghij".to_string(), "k".to_string()]
);
}
#[test]
fn stream_fragment_rendering_preserves_blank_lines() {
let mut lines = Vec::new();
push_wrapped_text(&mut lines, "a\n\n", 10, Style::default(), LineFill::Natural);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert_eq!(rendered, vec!["a".to_string(), String::new()]);
}
#[test]
fn visual_cursor_index_maps_row_and_column_to_char_index() {
let cases = [
("ab\ncdef", 80, 0, 4, 2),
("界a界b", 3, 0, 2, 1),
("abcdef", 4, 0, 2, 2),
("ab\ncdef", 80, 1, 2, 5),
("ab\ncdef", 80, 1, 9, 7),
("abcdef", 4, 1, 1, 5),
("界a界b", 3, 1, 2, 3),
("a\nb\ncd", 80, 2, 1, 5),
("hello world", 8, 1, 0, 6),
("hello world", 8, 1, 2, 8),
("hello world", 5, 1, 0, 5),
("hello world", 5, 1, 1, 6),
];
for (input, width, row, column, expected) in cases {
let lines = input_visual_lines(input, width);
assert_eq!(
input_cursor_index_on_visual_line(input, &lines, row, column),
expected,
"input {input:?} width {width} row {row} column {column} lines {lines:?}"
);
}
}
#[test]
fn input_char_index_at_position_matches_editable_layout() {
assert_eq!(input_char_index_at_position("hello", 80, 0, 2), 2);
assert_eq!(input_char_index_at_position("hello", 80, 0, 99), 5);
assert_eq!(input_char_index_at_position("abcdefghij", 5, 1, 2), 7);
assert_eq!(input_char_index_at_position("abcde", 5, 1, 0), 5);
}
#[test]
fn visual_caret_position_tracks_paint_rows() {
let caret = |input: &str, cursor: usize, width: usize| {
let lines = editable_input_visual_lines(input, width);
visual_caret_position(&lines, input, cursor)
};
assert_eq!(caret("hello", 2, 80), Position { x: 2, y: 0 });
assert_eq!(caret("hello", 5, 80), Position { x: 5, y: 0 });
assert_eq!(caret("ab\ncd", 2, 80), Position { x: 2, y: 0 });
assert_eq!(caret("ab\ncd", 3, 80), Position { x: 0, y: 1 });
assert_eq!(caret("hello world", 7, 8), Position { x: 1, y: 1 });
assert_eq!(caret("abcde", 5, 5), Position { x: 0, y: 1 });
assert_eq!(caret("héllo", 3, 80), Position { x: 3, y: 0 });
}
#[test]
fn composer_input_word_wraps_at_whitespace() {
assert_eq!(
input_visual_lines("hello wide world", 10),
vec!["hello wide".to_string(), " world".to_string()]
);
assert_eq!(
input_visual_lines("abcdefghijk", 5),
vec!["abcde".to_string(), "fghij".to_string(), "k".to_string()]
);
assert_eq!(
input_visual_lines("hello world\nnext line here", 10),
vec![
"hello ".to_string(),
"world".to_string(),
"next line ".to_string(),
"here".to_string(),
]
);
}
#[test]
fn composer_input_lines_highlight_survives_word_wrap() {
let frame = input_frame(
"hello world",
11,
8,
Some(6..11),
);
let lines = frame.lines;
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert_eq!(rendered, vec!["hello ".to_string(), "world".to_string()]);
assert_eq!(lines[1].spans.len(), 1);
assert_eq!(lines[1].spans[0].content.as_ref(), "world");
assert!(lines[1].spans[0]
.style
.add_modifier
.contains(ratatui::style::Modifier::REVERSED));
}
#[test]
fn picker_lists_more_items_on_a_taller_viewport() {
let items = (0..40)
.map(|index| PickerItem {
section: None,
label: format!("model-{index}"),
detail: None,
preview: None,
badge: None,
value: format!("model-{index}"),
selection_verb: None,
})
.collect();
let picker = UiPicker::new("models", items, PickerAction::SelectModel);
let item_rows = |height: usize| {
picker_lines(&picker, 80, height)
.iter()
.filter(|line| line_text(line).contains("model-"))
.count()
};
assert_eq!(item_rows(18), 8);
assert_eq!(item_rows(40), 30);
assert_eq!(item_rows(4), 1);
}
#[test]
fn picker_reserves_wrapped_footer_rows() {
use crate::tui::PickerKeyHints;
let items = (0..20)
.map(|index| PickerItem {
section: None,
label: format!("model-{index}"),
detail: None,
preview: None,
badge: None,
value: format!("model-{index}"),
selection_verb: None,
})
.collect();
let picker = UiPicker::new("select model", items, PickerAction::SelectModel).with_key_hints(
PickerKeyHints {
pin_toggle: Some("Ctrl+P".into()),
scope_toggle: Some("Ctrl+O".into()),
tab_complete: true,
..Default::default()
},
);
let lines = picker_lines(&picker, 80, 18);
let item_rows = lines
.iter()
.filter(|line| line_text(line).contains("model-"))
.count();
let footer: Vec<String> = lines
.iter()
.map(line_text)
.filter(|text| {
text.contains("Type to search")
|| text.contains("Enter select")
|| text.contains("Ctrl+P")
|| text.contains("Tab complete")
|| text.contains("Esc cancel")
})
.collect();
assert_eq!(item_rows, 7);
assert_eq!(
footer,
vec![
" select model · Type to search · Enter select · Ctrl+P pin/unpin".to_string(),
" Ctrl+O all/pinned · Tab complete · Esc cancel".to_string(),
]
);
assert!(footer.iter().all(|line| !line.contains('…')));
}
#[test]
fn pad_display_line_strips_underline_from_edge_spaces() {
let link = Theme::markdown_link();
let padded = pad_display_line(Line::from(Span::styled("https://example.com", link)));
assert_eq!(
padded
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<Vec<_>>(),
[" ", "https://example.com", " "]
);
assert_eq!(
padded.spans[0].style,
chrome_edge_style(link),
"leading gutter keeps colors without underline"
);
assert_eq!(padded.spans[1].style, link, "link body keeps underline");
assert_eq!(
padded.spans[2].style,
chrome_edge_style(link),
"trailing gutter keeps colors without underline"
);
assert!(!padded.spans[0]
.style
.add_modifier
.contains(Modifier::UNDERLINED));
assert!(!padded.spans[2]
.style
.add_modifier
.contains(Modifier::UNDERLINED));
}
#[test]
fn pad_display_line_keeps_user_message_background() {
let style = Theme::user_message();
let padded = pad_display_line(Line::from(Span::styled("hi", style)));
assert_eq!(padded.spans[0].style.bg, style.bg);
assert_eq!(padded.spans[2].style.bg, style.bg);
}
#[test]
fn error_entries_include_text_severity_marker() {
struct RestoreTheme(String);
impl Drop for RestoreTheme {
fn drop(&mut self) {
Theme::apply_committed(&self.0);
}
}
let _theme_lock = crate::tui::theme::theme_test_lock();
let _restore_theme = RestoreTheme(Theme::committed_id());
Theme::apply_committed("monochrome-dark");
let message = "could not save theme";
let error_lines = entry_lines(
&crate::tui::Entry::Error(message.into()),
40,
0,
0,
);
let notice_lines = entry_lines(
&crate::tui::Entry::Notice(message.into()),
40,
0,
0,
);
let error_text = line_text(&error_lines[0]);
let notice_text = line_text(¬ice_lines[0]);
assert_eq!(
error_text.trim(),
format!("error: {message}"),
"errors keep a stable text marker under monochrome"
);
assert_eq!(notice_text.trim(), message, "notices stay unmarked");
assert_ne!(
error_text.trim(),
notice_text.trim(),
"same body text must not collide once color is gone"
);
}
#[test]
fn duration_receipt_leaves_a_blank_row_after_body() {
let _theme_lock = crate::tui::theme::theme_test_lock();
let elapsed = std::time::Duration::from_millis(2_300);
let body_only = crate::tui::Entry::Assistant("Hello".into());
let with_receipt = crate::tui::Entry::Assistant(crate::tui::AssistantEntry {
text: "Hello".into(),
worked_for: Some(elapsed),
});
let summary_only =
crate::tui::Entry::Assistant(crate::tui::AssistantEntry::summary_only(elapsed));
let without = entry_lines(
&body_only, 40, 0, 0,
);
let with = entry_lines(
&with_receipt,
40,
0,
0,
);
let summary = entry_lines(
&summary_only,
40,
0,
0,
);
let without_text: Vec<String> = without.iter().map(line_text).collect();
let with_text: Vec<String> = with.iter().map(line_text).collect();
let summary_text: Vec<String> = summary.iter().map(line_text).collect();
let body_end = without_text.len() - 1;
assert_eq!(
&with_text[..body_end],
&without_text[..body_end],
"receipt should leave the body prefix unchanged: {with_text:?}"
);
assert!(
with_text[body_end].trim().is_empty(),
"body and receipt need a blank between them: {with_text:?}"
);
assert!(
!with_text[body_end + 1].trim().is_empty(),
"receipt should sit above the trailing spacer: {with_text:?}"
);
assert_eq!(
with_text.last(),
without_text.last(),
"receipt should keep the trailing spacer: {with_text:?}"
);
assert!(
!summary_text
.first()
.is_some_and(|line| line.trim().is_empty()),
"summary-only entries should not lead with a blank: {summary_text:?}"
);
}