use super::*; #[test] fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() { use ratatui::{Terminal, backend::TestBackend}; use std::time::Duration; let mut thumbnail = Thumbnail::new(); thumbnail.selected = Some("fixture".into()); let area = Rect::new(2, 2, 80, 8); let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); let mut loading_width = 0; terminal .draw(|frame| loading_width = thumbnail.draw(frame, area)) .unwrap(); let (_decode_tx, decode_rx) = mpsc::channel(); thumbnail.decoder = Some(decode_rx); let mut decoding_width = 0; terminal .draw(|frame| decoding_width = thumbnail.draw(frame, area)) .unwrap(); assert_eq!(loading_width, decoding_width); thumbnail.decoder = None; let (requests_tx, requests_rx) = mpsc::channel::(); let (replies_tx, replies_rx) = mpsc::channel(); thumbnail.image = Some(ThumbnailImage { id: "fixture".into(), protocol: ThreadProtocol::new( requests_tx, Some( thumbnail .picker .new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)), ), ), replies: replies_rx, complete: false, }); let mut preparing_width = 0; terminal .draw(|frame| preparing_width = thumbnail.draw(frame, area)) .unwrap(); assert_eq!(loading_width, preparing_width); terminal .draw(|frame| preparing_width = thumbnail.draw(frame, area)) .unwrap(); assert_eq!(loading_width, preparing_width); replies_tx .send(Ok(requests_rx .recv_timeout(Duration::from_secs(2)) .unwrap() .resize_encode() .unwrap())) .unwrap(); let mut ready_width = 0; terminal .draw(|frame| ready_width = thumbnail.draw(frame, area)) .unwrap(); assert_eq!( loading_width, ready_width, "right-hand content must not move when the image finishes loading" ); thumbnail.cancel(); thumbnail.select("fixture"); terminal .draw(|frame| ready_width = thumbnail.draw(frame, area)) .unwrap(); assert_eq!(loading_width, ready_width); replies_tx .send(Err("fixture renderer failure".into())) .unwrap(); let mut error_width = 0; terminal .draw(|frame| error_width = thumbnail.draw(frame, area)) .unwrap(); assert_eq!(loading_width, error_width); assert!(thumbnail.error.is_some()); } #[test] fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() { use ratatui::{Terminal, backend::TestBackend}; use std::time::Duration; let mut thumbnail = Thumbnail::new(); let url = "https://example.test/cover.png"; thumbnail.selected = Some(url.into()); thumbnail.aspect = (2, 3); let area = Rect::new(2, 2, 80, 8); let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3)); let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); let mut loading_width = 0; terminal .draw(|frame| loading_width = thumbnail.draw(frame, area)) .unwrap(); assert_eq!(loading_width, slot.width); // The artwork need not exactly match the placeholder's 2:3 aspect. let protocol = thumbnail .picker .new_resize_protocol(image::DynamicImage::new_rgb8(60, 100)); let (requests_tx, requests_rx) = mpsc::channel::(); let (replies_tx, replies_rx) = mpsc::channel(); thumbnail.image = Some(ThumbnailImage { id: url.into(), protocol: ThreadProtocol::new(requests_tx, Some(protocol)), replies: replies_rx, complete: false, }); terminal .draw(|frame| { assert_eq!(thumbnail.draw(frame, area), loading_width); }) .unwrap(); let response = requests_rx .recv_timeout(Duration::from_secs(2)) .unwrap() .resize_encode() .unwrap(); replies_tx.send(Ok(response)).unwrap(); terminal .draw(|frame| { assert_eq!(thumbnail.draw(frame, area), loading_width); }) .unwrap(); assert!(!thumbnail.is_loading()); let size = thumbnail .image .as_ref() .unwrap() .protocol .size_for(Resize::Scale(None), slot) .unwrap(); assert!(size.width <= slot.width && size.height <= slot.height); let font = thumbnail.picker.font_size(); let width = u32::from(size.width) * u32::from(font.width); let height = u32::from(size.height) * u32::from(font.height); assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height)); thumbnail.cancel(); thumbnail.select_url(url); assert!(thumbnail.fetch.is_none()); assert_eq!(thumbnail.selected.as_deref(), Some(url)); terminal .draw(|frame| { assert_eq!(thumbnail.draw(frame, area), loading_width); }) .unwrap(); thumbnail.clear_cache(); assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty()); } #[test] fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() { assert!(decode(b"not a JPEG".to_vec()).is_err()); assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err()); for format in [ image::ImageFormat::Jpeg, image::ImageFormat::Png, image::ImageFormat::WebP, ] { let mut bytes = Cursor::new(Vec::new()); image::DynamicImage::new_rgb8(2, 3) .write_to(&mut bytes, format) .unwrap(); let image = decode(bytes.into_inner()).unwrap(); assert_eq!((image.width(), image.height()), (2, 3)); } } #[test] fn cancellation_discards_queued_decode_completion() { let mut thumbnail = Thumbnail::new(); let (tx, rx) = mpsc::channel(); let (requests_tx, _requests_rx) = mpsc::channel(); let (_replies_tx, replies_rx) = mpsc::channel(); let protocol = thumbnail .picker .new_resize_protocol(image::DynamicImage::new_rgb8(1, 1)); thumbnail.selected = Some("old-video".into()); thumbnail.decoder = Some(rx); assert!( tx.send(Ok(ThumbnailImage { id: "old-video".into(), protocol: ThreadProtocol::new(requests_tx, Some(protocol)), replies: replies_rx, complete: false, })) .is_ok() ); thumbnail.cancel(); thumbnail.selected = Some("new-video".into()); thumbnail.poll(); assert!(thumbnail.image.is_none()); assert!(thumbnail.error.is_none()); } #[test] fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() { let mut thumbnail = Thumbnail::new(); let completed = |id: String| { let (requests, _receiver) = mpsc::channel(); let (_sender, replies) = mpsc::channel(); ThumbnailImage { id, protocol: ThreadProtocol::new( requests, Some(Picker::halfblocks().new_resize_protocol(image::DynamicImage::new_rgb8(1, 1))), ), replies, complete: true, } }; for index in 0..CACHE_SIZE { thumbnail.image = Some(completed(format!("video{index:06}"))); thumbnail.cancel(); } thumbnail.image = Some(completed("video000008".into())); thumbnail.selected = Some("video000008".into()); // This is the oldest cached image: storing the outgoing image first would evict it. thumbnail.select("video000000"); assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000"); assert!(thumbnail.fetch.is_none()); thumbnail.select("video000001"); assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001"); assert!(thumbnail.fetch.is_none()); thumbnail.cancel(); assert!( !thumbnail .cached .iter() .any(|image| image.id == "video000002") ); assert!( thumbnail .cached .iter() .any(|image| image.id == "video000000") ); assert!( thumbnail .cached .iter() .any(|image| image.id == "video000008") ); thumbnail.clear_cache(); assert!(thumbnail.cached.is_empty()); assert!(thumbnail.image.is_none()); } #[test] fn small_thumbnail_fills_popup_proportionally_and_resizes() { use ratatui::{Terminal, backend::TestBackend, style::Color}; use std::time::Duration; let mut thumbnail = Thumbnail::new(); let font = thumbnail.picker.font_size(); let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( 48, 36, image::Rgb([240, 40, 70]), )); let (requests_tx, requests_rx) = mpsc::channel::(); let (replies_tx, replies_rx) = mpsc::channel(); thumbnail.selected = Some("thumbnail".into()); thumbnail.image = Some(ThumbnailImage { id: "thumbnail".into(), protocol: ThreadProtocol::new( requests_tx, Some(thumbnail.picker.new_resize_protocol(image)), ), replies: replies_rx, complete: false, }); let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); for area in [ Rect::new(3, 2, 80, 18), Rect::new(7, 4, 30, 8), Rect::new(3, 2, 9, 18), Rect::new(7, 4, 30, 8), ] { let mut pending_width = 0; terminal .draw(|frame| { pending_width = thumbnail.draw(frame, area); }) .unwrap(); assert!(thumbnail.is_loading()); assert!( !terminal.backend().buffer().content.iter().any(|cell| { matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..)) }), "a resize must not render stale pixels" ); let response = requests_rx .recv_timeout(Duration::from_secs(2)) .unwrap() .resize_encode() .unwrap(); replies_tx.send(Ok(response)).unwrap(); let mut occupied_width = 0; terminal .draw(|frame| { occupied_width = thumbnail.draw(frame, area); }) .unwrap(); assert!(!thumbnail.is_loading()); assert_eq!(pending_width, occupied_width); let buffer = terminal.backend().buffer(); let pixels: Vec<_> = (0..40) .flat_map(|y| (0..100).map(move |x| (x, y))) .filter(|&(x, y)| { let cell = &buffer[(x, y)]; matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..)) }) .collect(); let left = pixels.iter().map(|&(x, _)| x).min().unwrap(); let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1; let top = pixels.iter().map(|&(_, y)| y).min().unwrap(); let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1; assert!(left >= area.x && right <= area.right()); assert_eq!(left, area.x); assert_eq!(occupied_width, right - left); assert!(top >= area.y && bottom <= area.bottom()); assert!(right - left == area.width || bottom - top == area.height); assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1); let width = u32::from(right - left) * u32::from(font.width); let height = u32::from(bottom - top) * u32::from(font.height); assert!( width.abs_diff(height * 4 / 3) <= u32::from(font.width) + u32::from(font.height) * 4 / 3 ); } }