332 lines
11 KiB
Rust
332 lines
11 KiB
Rust
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::<ResizeRequest>();
|
|
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::<ResizeRequest>();
|
|
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::<ResizeRequest>();
|
|
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
|
|
);
|
|
}
|
|
}
|