use pretty_assertions::assert_eq;
use super::*;
use crate::tui::render::{entry_lines, render_entry_with_images};
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn no_images(_: usize, _: &[MarkdownImageSource]) -> Vec<(usize, FeedImage)> {
Vec::new()
}
#[test]
fn caches_code_block_copy_target_and_raw_contents() {
let mut cache = HistoryLineCache::default();
let entries = vec![Entry::Assistant(
"before\n```rust\nlet x = 1;\nprintln!(\"{x}\");\n```\nafter".into(),
)];
let blocks = cache.code_blocks(&entries, 40, 10, &no_images);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text.as_ref(), "let x = 1;\nprintln!(\"{x}\");");
assert_eq!(blocks[0].line, 1);
assert_eq!(blocks[0].copy_columns, 32..38);
}
#[test]
fn caches_unicode_wrapped_lines_and_code_copy_target_without_rendering_drift() {
let mut cache = HistoryLineCache::default();
let entries = vec![Entry::Assistant("ä½ å¥½ä½ å¥½ä½ å¥½\n```text\nλ🙂\n```".into())];
let expected_lines = entry_lines(&entries[0], 12, 10);
let mut cached_lines = Vec::new();
cache.extend_visible_lines(
&entries,
12,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut cached_lines,
&no_images,
);
let blocks = cache.code_blocks(&entries, 12, 10, &no_images);
assert_eq!(cached_lines, expected_lines);
assert_eq!(
blocks,
&[CachedCodeBlock {
line: 2,
copy_columns: 4..10,
text: Arc::from("λ🙂"),
}]
);
}
#[test]
fn incrementally_extends_assistant_markdown_without_rendering_drift() {
let mut cache = HistoryLineCache::default();
let mut entries = vec![Entry::Assistant("intro\n\nheader | value\n".into())];
let mut cached_lines = Vec::new();
cache.extend_visible_lines(
&entries,
32,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut cached_lines,
&no_images,
);
let Entry::Assistant(text) = &mut entries[0] else {
unreachable!();
};
text.push_str("--- | ---\nrow | `one`\n");
cache.assistant_appended(0);
cached_lines.clear();
cache.extend_visible_lines(
&entries,
32,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut cached_lines,
&no_images,
);
assert_eq!(cached_lines, entry_lines(&entries[0], 32, 10));
let Entry::Assistant(text) = &mut entries[0] else {
unreachable!();
};
text.push_str("\n## streamed heading\n");
cache.assistant_appended(0);
cached_lines.clear();
cache.extend_visible_lines(
&entries,
32,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut cached_lines,
&no_images,
);
assert_eq!(cached_lines, entry_lines(&entries[0], 32, 10));
let Entry::Assistant(text) = &mut entries[0] else {
unreachable!();
};
text.push_str("\n```rust\nlet answer = 42;\n");
cache.assistant_appended(0);
cached_lines.clear();
cache.extend_visible_lines(
&entries,
32,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut cached_lines,
&no_images,
);
assert_eq!(cached_lines, entry_lines(&entries[0], 32, 10));
let Entry::Assistant(text) = &mut entries[0] else {
unreachable!();
};
text.push_str("println!(\"{answer}\");\n```\ndone\n");
cache.assistant_appended(0);
cached_lines.clear();
cache.extend_visible_lines(
&entries,
32,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut cached_lines,
&no_images,
);
assert_eq!(cached_lines, entry_lines(&entries[0], 32, 10));
assert_eq!(cache.code_blocks(&entries, 32, 10, &no_images).len(), 1);
assert!(cache.assistant_caches[0]
.is_some_and(|cached| cached.stable_source_len > "intro\n\n".len()));
}
#[test]
fn streams_mermaid_as_source_then_caches_the_closed_diagram_by_width() {
let mut cache = HistoryLineCache::default();
let mut entries = vec![Entry::Assistant(
"```mermaid\nflowchart LR\nA[Parse] --> B[Render]".into(),
)];
let mut cached_lines = Vec::new();
cache.extend_visible_lines(
&entries,
80,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut cached_lines,
&no_images,
);
assert_eq!(cached_lines, entry_lines(&entries[0], 80, 10));
assert!(cached_lines
.iter()
.any(|line| line_text(line).contains("flowchart LR")));
let Entry::Assistant(text) = &mut entries[0] else {
unreachable!();
};
text.push_str("\n```");
cache.assistant_appended(0);
cached_lines.clear();
cache.extend_visible_lines(
&entries,
80,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut cached_lines,
&no_images,
);
assert_eq!(cached_lines, entry_lines(&entries[0], 80, 10));
assert!(cached_lines
.iter()
.any(|line| line_text(line).contains("MERMAID")));
assert!(!cached_lines
.iter()
.any(|line| line_text(line).contains("flowchart LR")));
assert_eq!(
cache.code_blocks(&entries, 80, 10, &no_images)[0]
.text
.as_ref(),
"flowchart LR\nA[Parse] --> B[Render]"
);
let mut narrow_lines = Vec::new();
cache.extend_visible_lines(
&entries,
36,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut narrow_lines,
&no_images,
);
assert_eq!(narrow_lines, entry_lines(&entries[0], 36, 10));
assert_ne!(cached_lines, narrow_lines);
}
#[test]
fn resizing_moves_a_wide_diagram_between_art_and_explained_source() {
let source = crate::tui::markdown::PHASE_CHAIN_FLOWCHART;
let entries = vec![Entry::Assistant(format!("```mermaid\n{source}\n```"))];
let mut cache = HistoryLineCache::default();
let render_at = |cache: &mut HistoryLineCache, width: usize| {
let mut lines = Vec::new();
cache.extend_visible_lines(
&entries,
width,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut lines,
&no_images,
);
lines.iter().map(line_text).collect::<Vec<_>>()
};
let wide = render_at(&mut cache, 100);
assert!(wide.iter().any(|line| line.contains("Phase 5")), "{wide:?}");
assert!(!wide.iter().any(|line| line.contains("flowchart LR")));
let narrow = render_at(&mut cache, 40);
assert!(
narrow.iter().any(|line| line.contains("PANE TOO NARROW")),
"{narrow:?}"
);
assert!(narrow.iter().any(|line| line.contains("flowchart LR")));
assert_eq!(render_at(&mut cache, 100), wide);
for width in [100, 40] {
assert_eq!(
cache.code_blocks(&entries, width, 10, &no_images)[0]
.text
.as_ref(),
source
);
}
}
#[test]
fn invalidating_an_assistant_entry_refreshes_code_block_contents() {
let mut cache = HistoryLineCache::default();
let mut entries = vec![Entry::Assistant("```\nfirst\n```".into())];
assert_eq!(
cache.code_blocks(&entries, 30, 10, &no_images)[0]
.text
.as_ref(),
"first"
);
entries[0] = Entry::Assistant("```\nsecond\n```".into());
cache.invalidate_from(0);
assert_eq!(
cache.code_blocks(&entries, 30, 10, &no_images)[0]
.text
.as_ref(),
"second"
);
}
#[test]
fn markdown_image_placement_renders_after_images_resolve() {
use image::{DynamicImage, ImageFormat};
use rho_sdk::tool::ToolAsset;
use std::io::Cursor;
let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
300,
600,
image::Rgba([20, 40, 60, 255]),
));
let mut bytes = Cursor::new(Vec::new());
image.write_to(&mut bytes, ImageFormat::Png).unwrap();
let asset = ToolAsset::new("image/png", bytes.into_inner());
let mut picker = ratatui_image::picker::Picker::halfblocks();
picker.set_protocol_type(ratatui_image::picker::ProtocolType::Kitty);
let feed = FeedImage::load(&asset, &picker).unwrap();
let mut cache = HistoryLineCache::default();
let mut entries = vec\n\n{}\n```\ncopy me\n```",
(0..15)
.map(|index| format!("stable {index}"))
.collect::<Vec<_>>()
.join("\n")
))];
let placements = cache.visible_image_placements(&entries, 40, 20, 0, usize::MAX, &no_images);
assert!(placements.is_empty());
cache.invalidate_from(0);
let feed_clone = feed.clone();
let mut lines = Vec::new();
cache.extend_visible_lines(
&entries,
40,
20,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut lines,
&|_index, _sources| vec![(0, feed_clone.clone())],
);
let placements =
cache.visible_image_placements(&entries, 40, 20, 0, usize::MAX, &|_index, _sources| {
vec![(0, feed.clone())]
});
assert_eq!(placements.len(), 1, "expected one markdown image placement");
assert_eq!(placements[0].row, 2);
let with_images = render_entry_with_images(&entries[0], 40, 20, Some(&[(0, feed.clone())]));
assert_eq!(
cache.code_blocks(&entries, 40, 20, &|_index, _sources| vec![(
0,
feed.clone()
)])[0]
.line,
with_images.code_blocks[0].top_line
);
let Entry::Assistant(text) = &mut entries[0] else {
unreachable!();
};
text.push_str("\nstreamed tail");
cache.assistant_appended(0);
lines.clear();
cache.extend_visible_lines(
&entries,
40,
20,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut lines,
&|_index, _sources| vec![(0, feed.clone())],
);
let expected = render_entry_with_images(&entries[0], 40, 20, Some(&[(0, feed.clone())])).lines;
assert_eq!(lines, expected);
}
#[test]
fn transcript_entries_use_trailing_blank_only() {
let mut cache = HistoryLineCache::default();
let entries = vec![
Entry::Assistant("Hello model output".into()),
Entry::Reasoning(crate::tui::ReasoningEntry::new("thinking about it")),
Entry::User("follow up".into()),
];
let mut lines = Vec::new();
cache.extend_visible_lines(
&entries,
60,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut lines,
&no_images,
);
let expected = entries
.iter()
.flat_map(|entry| entry_lines(entry, 60, 10))
.collect::<Vec<_>>();
assert_eq!(lines, expected);
let mut content_starts = Vec::new();
for (index, line) in lines.iter().enumerate() {
if !line_text(line).trim().is_empty() {
content_starts.push(index);
}
}
assert!(content_starts.len() >= 3);
assert_eq!(content_starts[0], 0);
for window in content_starts.windows(2) {
assert!(
window[1] == window[0] + 1 || window[1] == window[0] + 2,
"unexpected gap between content rows {}:{} in {lines:?}",
window[0],
window[1],
lines = lines.iter().map(line_text).collect::<Vec<_>>()
);
}
let first_entry_end = entry_lines(&entries[0], 60, 10).len();
assert!(!line_text(&lines[0]).trim().is_empty());
assert!(line_text(&lines[first_entry_end - 1]).trim().is_empty());
assert!(!line_text(&lines[first_entry_end]).trim().is_empty());
}
#[test]
fn open_stream_tail_omits_trailing_blank_until_closed() {
use crate::tui::render::{render_entry_with_options, TrailingBlank};
let mut cache = HistoryLineCache::default();
let entries = vec![Entry::Assistant("Hello committed line\n".into())];
cache.set_open_stream_tail(true);
let open_count = cache.line_count(&entries, 60, 10, &no_images);
let mut open_lines = Vec::new();
cache.extend_visible_lines(
&entries,
60,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut open_lines,
&no_images,
);
assert_eq!(open_lines.len(), open_count);
assert_eq!(
open_lines,
render_entry_with_options(&entries[0], 60, 10, TrailingBlank::Omit).lines
);
cache.set_open_stream_tail(false);
let closed_count = cache.line_count(&entries, 60, 10, &no_images);
assert_eq!(closed_count, open_count + 1);
let mut closed_lines = Vec::new();
cache.extend_visible_lines(
&entries,
60,
10,
HistoryLineSlice {
start: 0,
count: usize::MAX,
},
&mut closed_lines,
&no_images,
);
assert_eq!(closed_lines, entry_lines(&entries[0], 60, 10));
}