use std::io::Cursor;
use image::{DynamicImage, ImageFormat};
use ratatui_image::picker::{Picker, ProtocolType};
use rho_sdk::tool::ToolAsset;
use super::{
feed_image_height_budget, kitty_graphics_environment, max_feed_image_height,
picker_for_environment, quantize_content_image_cap, reserve_image_rows, FeedImage,
COMPACT_IMAGE_HEIGHT, COMPOSER_IMAGE_HEIGHT, DEFAULT_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT,
MIN_IMAGE_HEIGHT, TALL_IMAGE_HEIGHT,
};
use crate::tui::{
history_cache::{HistoryLineCache, HistoryLineSlice, HistoryRenderSettings},
Entry, ToolEntry,
};
fn no_images(
_: usize,
_: &[crate::tui::markdown_image::MarkdownImageSource],
) -> Vec<(usize, FeedImage)> {
Vec::new()
}
fn png_asset(width: u32, height: u32) -> ToolAsset {
let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
width,
height,
image::Rgba([20, 40, 60, 255]),
));
let mut bytes = Cursor::new(Vec::new());
image.write_to(&mut bytes, ImageFormat::Png).unwrap();
ToolAsset::new("image/png", bytes.into_inner())
}
fn kitty_picker() -> Picker {
let mut picker = Picker::halfblocks();
picker.set_protocol_type(ProtocolType::Kitty);
picker
}
fn image_tool() -> Entry {
Entry::Tool(ToolEntry::new(
rho_tools::tool_card::ToolCard::new(
rho_tools::tool_card::ToolStatus::Ok,
rho_tools::tool_card::ToolFamily::Default,
rho_tools::tool_card::ToolHeader::call("read_file photo.png", None),
),
false,
Some(FeedImage::load(&png_asset(300, 600), &kitty_picker()).unwrap()),
None,
))
}
#[test]
fn loads_a_valid_bounded_asset_for_kitty_rendering() {
let image = FeedImage::load(&png_asset(2, 1), &kitty_picker()).unwrap();
let backend = ratatui::backend::TestBackend::new(20, DEFAULT_IMAGE_HEIGHT);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| image.render(frame, frame.area()))
.unwrap();
assert!(terminal
.backend()
.buffer()
.content
.iter()
.any(|cell| cell.symbol().contains("\x1b_G")));
}
#[test]
fn terminal_hints_enable_direct_kitty_and_ghostty_but_not_tmux() {
assert!(!kitty_graphics_environment(true, false, false, None, None));
assert!(kitty_graphics_environment(false, true, false, None, None));
assert!(kitty_graphics_environment(
false,
false,
false,
Some("Ghostty"),
None
));
assert!(!kitty_graphics_environment(
true,
true,
true,
Some("kitty"),
Some("xterm-kitty")
));
assert!(!kitty_graphics_environment(
false,
false,
false,
Some("Apple_Terminal"),
Some("xterm-256color")
));
}
#[test]
fn herdr_without_paintable_kitty_uses_halfblocks() {
let picker = picker_for_environment(
true,
crate::herdr::HerdrGraphicsCapability::Unpaintable,
)
.unwrap();
assert_eq!(picker.protocol_type(), ProtocolType::Halfblocks);
}
#[test]
fn herdr_with_paintable_kitty_keeps_kitty_protocol() {
let picker = picker_for_environment(
true,
crate::herdr::HerdrGraphicsCapability::Paintable,
)
.unwrap();
assert_eq!(picker.protocol_type(), ProtocolType::Kitty);
}
#[test]
fn rejects_assets_larger_than_the_thumbnail_dimension_bound() {
let error = FeedImage::load(&png_asset(1_025, 1), &kitty_picker()).unwrap_err();
assert!(matches!(error, image::ImageError::Limits(_)));
}
#[test]
fn composer_base64_preview_accepts_oversized_source_images() {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let bytes = {
let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
1_800,
1_200,
image::Rgba([10, 20, 30, 255]),
));
let mut encoded = Cursor::new(Vec::new());
image
.write_to(&mut encoded, ImageFormat::Png)
.expect("encode png");
encoded.into_inner()
};
assert!(matches!(
FeedImage::decode(&bytes),
Err(image::ImageError::Limits(_))
));
let decoded = FeedImage::decode_composer_base64(&STANDARD.encode(bytes))
.expect("composer preview should thumbnail oversized pastes");
let image = decoded.to_feed_image(&kitty_picker());
assert_eq!(
image.height_for_width(40, COMPOSER_IMAGE_HEIGHT),
usize::from(COMPOSER_IMAGE_HEIGHT)
);
}
#[test]
fn feed_image_height_budget_uses_terminal_height_bands() {
assert_eq!(max_feed_image_height(0), COMPACT_IMAGE_HEIGHT);
assert_eq!(max_feed_image_height(24), COMPACT_IMAGE_HEIGHT);
assert_eq!(max_feed_image_height(25), MIN_IMAGE_HEIGHT);
assert_eq!(max_feed_image_height(37), DEFAULT_IMAGE_HEIGHT);
assert_eq!(max_feed_image_height(53), TALL_IMAGE_HEIGHT);
assert_eq!(max_feed_image_height(69), MAX_IMAGE_HEIGHT);
assert!(usize::from(COMPACT_IMAGE_HEIGHT) <= 16);
}
#[test]
fn feed_image_height_budget_caps_to_history_content_viewport() {
assert_eq!(max_feed_image_height(40), DEFAULT_IMAGE_HEIGHT);
assert_eq!(feed_image_height_budget(40, 0), DEFAULT_IMAGE_HEIGHT);
assert_eq!(feed_image_height_budget(40, 30), DEFAULT_IMAGE_HEIGHT);
assert_eq!(feed_image_height_budget(40, 10), 10);
assert_eq!(feed_image_height_budget(40, 1), 1);
assert_eq!(feed_image_height_budget(40, 20), MIN_IMAGE_HEIGHT);
assert_eq!(quantize_content_image_cap(20), MIN_IMAGE_HEIGHT);
assert_eq!(quantize_content_image_cap(15), COMPACT_IMAGE_HEIGHT);
}
#[test]
fn feed_image_height_budget_stable_across_one_row_content_jitter() {
let terminal = 80usize; let budget_a = feed_image_height_budget(terminal, 37);
let budget_b = feed_image_height_budget(terminal, 36);
let budget_c = feed_image_height_budget(terminal, 33);
assert_eq!(budget_a, TALL_IMAGE_HEIGHT);
assert_eq!(budget_b, TALL_IMAGE_HEIGHT);
assert_eq!(budget_c, TALL_IMAGE_HEIGHT);
assert_ne!(
feed_image_height_budget(terminal, 32),
feed_image_height_budget(terminal, 31)
);
}
#[test]
fn content_capped_budget_keeps_tall_image_paintable_in_shrunken_viewport() {
let entries = vec![image_tool()];
let mut cache = HistoryLineCache::default();
let width = 40;
let content_height = 10usize;
let budget = feed_image_height_budget(48, content_height);
assert_eq!(budget, 10);
assert!(
usize::from(budget) <= content_height,
"reservation must not exceed the content viewport"
);
let settings = HistoryRenderSettings {
width,
max_tool_output_lines: 20,
zen_mode: false,
theme_generation: 0,
max_image_height: budget,
};
let line_count = cache.line_count(&entries, settings, &no_images);
let full = cache.visible_image_placements(&entries, settings, 0, line_count, &no_images);
assert_eq!(full.len(), 1);
assert_eq!(full[0].height, usize::from(budget));
let image_start = full[0].row;
let image_end = image_start + full[0].height;
let scroll = image_start.min(line_count.saturating_sub(content_height));
let placements =
cache.visible_image_placements(&entries, settings, scroll, content_height, &no_images);
assert_eq!(
placements.len(),
1,
"capped reservation must fully fit some content_height window (scroll={scroll}, image={image_start}..{image_end}, lines={line_count})"
);
let uncapped = max_feed_image_height(48);
assert!(usize::from(uncapped) > content_height);
let mut lines = Vec::new();
let tall = FeedImage::load(&png_asset(300, 600), &kitty_picker()).unwrap();
let placement = reserve_image_rows(&mut lines, &tall, width, uncapped);
assert!(
placement.rows.end - placement.rows.start > content_height,
"uncapped reservation must exceed the shrunken content pane"
);
}
#[test]
fn derives_reserved_rows_from_the_thumbnail_aspect_ratio() {
let wide = FeedImage::load(&png_asset(600, 100), &kitty_picker()).unwrap();
let tall = FeedImage::load(&png_asset(300, 600), &kitty_picker()).unwrap();
let budget = DEFAULT_IMAGE_HEIGHT;
assert!(wide.height_for_width(40, budget) < usize::from(budget));
assert_eq!(tall.height_for_width(40, budget), usize::from(budget));
}
#[test]
fn tool_entry_history_cache_omits_partially_visible_image_placement() {
let entries = vec![image_tool()];
let mut cache = HistoryLineCache::default();
let width = 40;
let budget = DEFAULT_IMAGE_HEIGHT;
let settings = HistoryRenderSettings {
width,
max_tool_output_lines: 20,
zen_mode: false,
theme_generation: 0,
max_image_height: budget,
};
let line_count = cache.line_count(&entries, settings, &no_images);
let full = cache.visible_image_placements(&entries, settings, 0, line_count, &no_images);
assert_eq!(full.len(), 1);
assert_eq!(full[0].row, 1);
assert_eq!(full[0].height, usize::from(budget));
let partial = cache.visible_image_placements(&entries, settings, 6, 4, &no_images);
assert!(partial.is_empty());
let mut visible_lines = Vec::new();
cache.extend_visible_lines(
&entries,
settings,
HistoryLineSlice { start: 6, count: 4 },
&mut visible_lines,
&no_images,
);
assert_eq!(visible_lines.len(), 4);
assert!(visible_lines
.iter()
.all(|line| line.to_string().trim().is_empty()));
}