use std::path::{Path, PathBuf};
use crate::{CursorError, Theme, ThemeResolution};
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new(name: &str) -> Self {
let mut root = std::env::temp_dir();
root.push(format!("drmkit-cursor-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("scratch root");
Self { root }
}
fn theme(&self, name: &str, inherits: &[&str]) -> PathBuf {
let dir = self.root.join(name);
std::fs::create_dir_all(dir.join("cursors")).expect("theme dir");
if !inherits.is_empty() {
let body = format!(
"[Icon Theme]\nName={name}\nInherits={}\n",
inherits.join(",")
);
std::fs::write(dir.join("index.theme"), body).expect("index.theme");
}
dir
}
fn theme_with_index(&self, name: &str, index: &str) -> PathBuf {
let dir = self.root.join(name);
std::fs::create_dir_all(dir.join("cursors")).expect("theme dir");
std::fs::write(dir.join("index.theme"), index).expect("index.theme");
dir
}
fn cursor(dir: &Path, name: &str) {
std::fs::write(dir.join("cursors").join(name), b"").expect("cursor file");
}
fn discover(&self) -> Result<Theme, CursorError> {
Theme::discover_with_paths(std::slice::from_ref(&self.root))
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
#[test]
fn discovery_finds_a_theme() {
let fixture = Fixture::new("discover");
fixture.theme("Adwaita", &[]);
let theme = fixture.discover().expect("discover");
assert_eq!(theme.theme_names(), vec!["Adwaita"]);
}
#[test]
fn discovery_fails_when_nothing_is_installed() {
let fixture = Fixture::new("empty");
assert_eq!(fixture.discover().unwrap_err(), CursorError::NoThemes);
}
#[test]
fn a_directory_with_only_metadata_is_still_indexed() {
let fixture = Fixture::new("metadata-only");
let dir = fixture.root.join("Bridge");
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("index.theme"), "[Icon Theme]\nInherits=Base\n").expect("index");
let theme = fixture.discover().expect("discover");
assert_eq!(theme.theme_names(), vec!["Bridge"]);
}
#[test]
fn a_directory_that_says_nothing_is_not_a_theme() {
let fixture = Fixture::new("not-a-theme");
std::fs::create_dir_all(fixture.root.join("random")).expect("dir");
fixture.theme("Real", &[]);
let theme = fixture.discover().expect("discover");
assert_eq!(theme.theme_names(), vec!["Real"]);
}
#[test]
fn resolve_finds_a_cursor_in_the_requested_theme() {
let fixture = Fixture::new("direct");
let dir = fixture.theme("Adwaita", &[]);
Fixture::cursor(&dir, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Adwaita").expect("resolve");
assert_eq!(found.theme_name, "Adwaita");
assert_eq!(found.source, dir.join("cursors").join("left_ptr"));
}
#[test]
fn resolve_reports_a_miss() {
let fixture = Fixture::new("miss");
fixture.theme("Adwaita", &[]);
let theme = fixture.discover().expect("discover");
assert_eq!(
theme.resolve("nonexistent-shape", "Adwaita").unwrap_err(),
CursorError::NotFound
);
}
#[test]
fn resolve_walks_the_inherits_chain() {
let fixture = Fixture::new("inherits");
fixture.theme("Child", &["Parent"]);
let parent = fixture.theme("Parent", &[]);
Fixture::cursor(&parent, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Child").expect("resolve");
assert_eq!(found.theme_name, "Parent");
assert!(found.chain.contains(&"Child".to_owned()));
assert!(found.chain.contains(&"Parent".to_owned()));
}
#[test]
fn resolve_walks_the_chain_transitively() {
let fixture = Fixture::new("transitive");
fixture.theme("Child", &["Middle"]);
fixture.theme("Middle", &["Grandparent"]);
let grandparent = fixture.theme("Grandparent", &[]);
Fixture::cursor(&grandparent, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Child").expect("resolve");
assert_eq!(found.theme_name, "Grandparent");
assert_eq!(
found.chain,
vec!["Child", "Middle", "Grandparent"],
"the chain reports the walk in order"
);
}
#[test]
fn an_alias_finds_the_legacy_name() {
let fixture = Fixture::new("alias-legacy");
let dir = fixture.theme("Adwaita", &[]);
Fixture::cursor(&dir, "sb_h_double_arrow");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("ew-resize", "Adwaita").expect("resolve");
assert_eq!(found.source.file_name().unwrap(), "sb_h_double_arrow");
}
#[test]
fn an_alias_finds_the_modern_name() {
let fixture = Fixture::new("alias-modern");
let dir = fixture.theme("Adwaita", &[]);
Fixture::cursor(&dir, "pointer");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("hand2", "Adwaita").expect("resolve");
assert_eq!(found.source.file_name().unwrap(), "pointer");
}
#[test]
fn alias_order_is_the_tables_not_the_requests() {
let fixture = Fixture::new("alias-order");
let dir = fixture.theme("Adwaita", &[]);
Fixture::cursor(&dir, "pointer");
Fixture::cursor(&dir, "hand2");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("hand2", "Adwaita").expect("resolve");
assert_eq!(
found.source.file_name().unwrap(),
"pointer",
"the class is ordered, and `pointer` comes first in it"
);
}
#[test]
fn a_name_in_no_class_resolves_as_itself() {
let fixture = Fixture::new("no-class");
let dir = fixture.theme("Adwaita", &[]);
Fixture::cursor(&dir, "my-custom-shape");
let theme = fixture.discover().expect("discover");
let found = theme
.resolve("my-custom-shape", "Adwaita")
.expect("resolve");
assert_eq!(found.source.file_name().unwrap(), "my-custom-shape");
}
#[test]
fn resolve_falls_back_through_indexed_themes() {
let fixture = Fixture::new("fallback");
fixture.theme("Named", &[]);
let other = fixture.theme("Other", &[]);
Fixture::cursor(&other, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Named").expect("resolve");
assert_eq!(found.theme_name, "Other");
}
#[test]
fn a_path_traversal_name_is_refused() {
let fixture = Fixture::new("traversal");
fixture.theme("Adwaita", &[]);
let theme = fixture.discover().expect("discover");
for name in [
"..",
".",
"",
"../../etc/passwd",
"sub/dir",
"back\\slash",
"../Adwaita/cursors/left_ptr",
] {
assert_eq!(
theme.resolve(name, "Adwaita").unwrap_err(),
CursorError::InvalidName,
"{name:?} must be refused"
);
}
}
#[test]
fn a_refused_name_never_reaches_the_cache() {
let fixture = Fixture::new("traversal-cache");
fixture.theme("Adwaita", &[]);
let theme = fixture.discover().expect("discover");
for _ in 0..8 {
assert_eq!(
theme.resolve("../escape", "Adwaita").unwrap_err(),
CursorError::InvalidName
);
}
assert_eq!(theme.cache_len(), 0, "refused names must not be memoized");
let _ = theme.resolve("left_ptr", "Adwaita");
assert_eq!(theme.cache_len(), 1);
}
#[test]
fn the_inherits_parser_takes_a_semicolon() {
let fixture = Fixture::new("semicolon");
fixture.theme_with_index("Child", "[Icon Theme]\nName=Child\nInherits=Parent;Other\n");
let parent = fixture.theme("Parent", &[]);
Fixture::cursor(&parent, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Child").expect("resolve");
assert_eq!(found.theme_name, "Parent");
}
#[test]
fn the_inherits_parser_ignores_comments_and_other_sections() {
let fixture = Fixture::new("sections");
fixture.theme_with_index(
"Child",
"# a comment\n\
[Icon Theme]\n\
# Inherits=NotThisOne\n\
Name=Child\n\
Inherits=Parent\n\
\n\
[X-Something]\n\
Inherits=NorThisOne\n",
);
let parent = fixture.theme("Parent", &[]);
Fixture::cursor(&parent, "left_ptr");
let decoy = fixture.theme("NotThisOne", &[]);
Fixture::cursor(&decoy, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Child").expect("resolve");
assert_eq!(found.theme_name, "Parent");
}
#[test]
fn an_inherits_before_the_icon_theme_section_is_ignored() {
let fixture = Fixture::new("preamble");
fixture.theme_with_index("Child", "Inherits=Wrong\n[Icon Theme]\nInherits=Parent\n");
let parent = fixture.theme("Parent", &[]);
Fixture::cursor(&parent, "left_ptr");
let wrong = fixture.theme("Wrong", &[]);
Fixture::cursor(&wrong, "left_ptr");
let theme = fixture.discover().expect("discover");
assert_eq!(
theme
.resolve("left_ptr", "Child")
.expect("resolve")
.theme_name,
"Parent"
);
}
#[test]
fn a_key_that_merely_starts_with_inherits_is_not_it() {
let fixture = Fixture::new("prefix");
fixture.theme_with_index("Child", "[Icon Theme]\nInheritsFrom=Wrong\n");
let wrong = fixture.theme("Wrong", &[]);
Fixture::cursor(&wrong, "left_ptr");
let other = fixture.theme("Other", &[]);
Fixture::cursor(&other, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Child").expect("resolve");
assert!(
!found.chain.contains(&"Wrong".to_owned())
|| found.chain.iter().position(|t| t == "Wrong")
> found.chain.iter().position(|t| t == "Child"),
"Wrong must not have been reached as Child's parent: {:?}",
found.chain
);
}
#[test]
fn resolution_is_memoized() {
let fixture = Fixture::new("memo");
let dir = fixture.theme("Adwaita", &[]);
Fixture::cursor(&dir, "left_ptr");
let theme = fixture.discover().expect("discover");
let first = theme.resolve("left_ptr", "Adwaita").expect("resolve");
std::fs::remove_file(dir.join("cursors").join("left_ptr")).expect("remove");
let second = theme.resolve("left_ptr", "Adwaita").expect("still cached");
assert_eq!(first, second);
}
#[test]
fn a_miss_is_memoized_too() {
let fixture = Fixture::new("memo-miss");
let dir = fixture.theme("Adwaita", &[]);
let theme = fixture.discover().expect("discover");
assert_eq!(
theme.resolve("left_ptr", "Adwaita").unwrap_err(),
CursorError::NotFound
);
Fixture::cursor(&dir, "left_ptr");
assert_eq!(
theme.resolve("left_ptr", "Adwaita").unwrap_err(),
CursorError::NotFound,
"the negative result was memoized"
);
let reindexed = fixture.discover().expect("discover");
assert!(reindexed.resolve("left_ptr", "Adwaita").is_ok());
}
#[test]
fn the_chain_is_deduplicated() {
let fixture = Fixture::new("diamond");
fixture.theme("Child", &["Left", "Right"]);
fixture.theme("Left", &["Base"]);
fixture.theme("Right", &["Base"]);
let base = fixture.theme("Base", &[]);
Fixture::cursor(&base, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Child").expect("resolve");
assert_eq!(found.theme_name, "Base");
let mut seen = found.chain.clone();
seen.sort();
let deduped = {
let mut copy = seen.clone();
copy.dedup();
copy
};
assert_eq!(seen, deduped, "chain has a repeat: {:?}", found.chain);
}
#[test]
fn a_missing_parent_is_recorded_and_survived() {
let fixture = Fixture::new("ghost-parent");
fixture.theme("Child", &["NotInstalled", "Parent"]);
let parent = fixture.theme("Parent", &[]);
Fixture::cursor(&parent, "left_ptr");
let theme = fixture.discover().expect("discover");
let found = theme.resolve("left_ptr", "Child").expect("resolve");
assert_eq!(found.theme_name, "Parent");
assert!(
found.chain.contains(&"NotInstalled".to_owned()),
"a parent that is missing is still part of the walk: {:?}",
found.chain
);
}
#[test]
fn an_inherits_cycle_terminates() {
let fixture = Fixture::new("cycle");
fixture.theme("A", &["B"]);
fixture.theme("B", &["A"]);
let theme = fixture.discover().expect("discover");
assert_eq!(
theme.resolve("left_ptr", "A").unwrap_err(),
CursorError::NotFound
);
}
#[test]
fn the_first_search_path_wins() {
let outer = Fixture::new("priority");
let user = outer.root.join("user");
let system = outer.root.join("system");
for root in [&user, &system] {
std::fs::create_dir_all(root.join("Adwaita").join("cursors")).expect("dirs");
}
std::fs::write(user.join("Adwaita/cursors/left_ptr"), b"user").expect("user cursor");
std::fs::write(system.join("Adwaita/cursors/left_ptr"), b"system").expect("system cursor");
let theme = Theme::discover_with_paths(&[user.clone(), system]).expect("discover");
let found = theme.resolve("left_ptr", "Adwaita").expect("resolve");
assert!(
found.source.starts_with(&user),
"the earlier search path must win: {:?}",
found.source
);
}
#[test]
fn the_default_search_path_is_ordered_user_first() {
let paths = crate::default_search_paths();
assert!(!paths.is_empty());
let strings: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
if let (Some(user), Some(system)) = (
strings.iter().position(|p| p.contains("/.icons")),
strings
.iter()
.position(|p| p.starts_with("/usr/share/icons")),
) {
assert!(user < system, "user paths come first: {strings:?}");
}
assert_eq!(
strings.last().map(String::as_str),
Some("/usr/share/pixmaps"),
"the pixmaps leftover is last: {strings:?}"
);
}
#[test]
fn a_resolution_reports_where_it_looked() {
let fixture = Fixture::new("chain-report");
fixture.theme("Child", &["Parent"]);
let parent = fixture.theme("Parent", &[]);
Fixture::cursor(&parent, "left_ptr");
let theme = fixture.discover().expect("discover");
let ThemeResolution {
theme_name,
source,
chain,
} = theme.resolve("left_ptr", "Child").expect("resolve");
assert_eq!(theme_name, "Parent");
assert!(source.is_absolute() || source.exists());
assert_eq!(chain.first().map(String::as_str), Some("Child"));
}
use crate::Cursor;
use std::time::Duration;
#[derive(Debug, Clone)]
struct FileBuilder {
images: Vec<ImageSpec>,
declared_entries: Option<u32>,
truncate_to: Option<usize>,
}
#[derive(Debug, Clone)]
struct ImageSpec {
nominal_size: u32,
width: u32,
height: u32,
xhot: u32,
yhot: u32,
delay_ms: u32,
fill: u32,
short_by: usize,
}
impl ImageSpec {
fn new(nominal_size: u32, width: u32, height: u32) -> Self {
Self {
nominal_size,
width,
height,
xhot: 0,
yhot: 0,
delay_ms: 0,
fill: 0xffff_0000,
short_by: 0,
}
}
fn delay(mut self, ms: u32) -> Self {
self.delay_ms = ms;
self
}
fn fill(mut self, pixel: u32) -> Self {
self.fill = pixel;
self
}
fn hot(mut self, x: u32, y: u32) -> Self {
self.xhot = x;
self.yhot = y;
self
}
}
impl FileBuilder {
fn new() -> Self {
Self {
images: Vec::new(),
declared_entries: None,
truncate_to: None,
}
}
fn image(mut self, spec: ImageSpec) -> Self {
self.images.push(spec);
self
}
fn build(&self) -> Vec<u8> {
const HEADER_LEN: u32 = 16;
let toc_len = self.images.len() * 12;
let body_start = HEADER_LEN as usize + toc_len;
let mut chunks: Vec<Vec<u8>> = Vec::new();
let mut offsets: Vec<u32> = Vec::new();
let mut at = body_start;
for spec in &self.images {
offsets.push(u32::try_from(at).expect("small file"));
let mut chunk = Vec::new();
for value in [
0x24,
0xfffd_0002,
spec.nominal_size,
1,
spec.width,
spec.height,
spec.xhot,
spec.yhot,
spec.delay_ms,
] {
chunk.extend_from_slice(&u32::to_le_bytes(value));
}
let declared = spec.width as usize * spec.height as usize;
for _ in 0..declared.saturating_sub(spec.short_by) {
chunk.extend_from_slice(&spec.fill.to_le_bytes());
}
at += chunk.len();
chunks.push(chunk);
}
let mut out = Vec::new();
out.extend_from_slice(b"Xcur");
out.extend_from_slice(&HEADER_LEN.to_le_bytes());
out.extend_from_slice(&1u32.to_le_bytes());
let entries = self
.declared_entries
.unwrap_or_else(|| u32::try_from(self.images.len()).expect("small"));
out.extend_from_slice(&entries.to_le_bytes());
for (spec, offset) in self.images.iter().zip(&offsets) {
out.extend_from_slice(&0xfffd_0002u32.to_le_bytes());
out.extend_from_slice(&spec.nominal_size.to_le_bytes());
out.extend_from_slice(&offset.to_le_bytes());
}
for chunk in &chunks {
out.extend_from_slice(chunk);
}
if let Some(limit) = self.truncate_to {
out.truncate(limit);
}
out
}
}
#[test]
fn a_static_cursor_carries_its_metadata() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 4, 3).hot(1, 2).fill(0xff00_ff00))
.build();
let cursor = Cursor::from_bytes(&bytes, 24).expect("load");
assert_eq!(cursor.frames().len(), 1);
let frame = cursor.first();
assert_eq!((frame.width, frame.height), (4, 3));
assert_eq!((frame.xhot, frame.yhot), (1, 2));
assert_eq!(frame.pixels.len(), 12);
assert!(frame.pixels.iter().all(|p| *p == 0xff00_ff00));
assert!(!cursor.is_animated());
assert_eq!(cursor.cycle(), Duration::ZERO);
}
#[test]
fn a_static_cursor_shows_the_same_frame_forever() {
let bytes = FileBuilder::new().image(ImageSpec::new(24, 2, 2)).build();
let cursor = Cursor::from_bytes(&bytes, 24).expect("load");
for ms in [0u64, 1, 1_000, 86_400_000] {
assert_eq!(cursor.frame_at(Duration::from_millis(ms)), cursor.first());
}
}
#[test]
fn an_animated_cursor_reports_its_cycle() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 2, 2).delay(100))
.image(ImageSpec::new(24, 2, 2).delay(150))
.image(ImageSpec::new(24, 2, 2).delay(50))
.build();
let cursor = Cursor::from_bytes(&bytes, 24).expect("load");
assert_eq!(cursor.frames().len(), 3);
assert!(cursor.is_animated());
assert_eq!(cursor.cycle(), Duration::from_millis(300));
}
#[test]
fn frame_at_walks_the_delays() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 2, 2).delay(100).fill(1))
.image(ImageSpec::new(24, 2, 2).delay(150).fill(2))
.image(ImageSpec::new(24, 2, 2).delay(50).fill(3))
.build();
let cursor = Cursor::from_bytes(&bytes, 24).expect("load");
let shown = |ms: u64| cursor.frame_at(Duration::from_millis(ms)).pixels[0];
assert_eq!(shown(0), 1);
assert_eq!(shown(99), 1);
assert_eq!(shown(100), 2, "the first frame's 100ms is up");
assert_eq!(shown(249), 2);
assert_eq!(shown(250), 3);
assert_eq!(shown(299), 3);
}
#[test]
fn frame_at_wraps_at_the_cycle() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 2, 2).delay(100).fill(1))
.image(ImageSpec::new(24, 2, 2).delay(200).fill(2))
.build();
let cursor = Cursor::from_bytes(&bytes, 24).expect("load");
let shown = |ms: u64| cursor.frame_at(Duration::from_millis(ms)).pixels[0];
assert_eq!(
shown(300),
shown(0),
"one full cycle later is the same frame"
);
assert_eq!(shown(350), shown(50));
assert_eq!(shown(3_000_000_050), shown(50), "and much later still");
}
#[test]
fn frames_that_all_declare_no_delay_are_static() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 2, 2).fill(1))
.image(ImageSpec::new(24, 2, 2).fill(2))
.build();
let cursor = Cursor::from_bytes(&bytes, 24).expect("load");
assert_eq!(cursor.frames().len(), 2);
assert!(!cursor.is_animated());
assert_eq!(cursor.frame_at(Duration::from_millis(500)).pixels[0], 1);
}
#[test]
fn the_nearest_size_is_chosen() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(16, 2, 2).fill(16))
.image(ImageSpec::new(32, 2, 2).fill(32))
.image(ImageSpec::new(64, 2, 2).fill(64))
.build();
for (requested, want) in [
(16, 16),
(20, 16),
(30, 32),
(32, 32),
(48, 32),
(64, 64),
(99, 64),
] {
let cursor = Cursor::from_bytes(&bytes, requested).expect("load");
assert_eq!(
cursor.first().pixels[0],
want,
"asking for {requested} should pick {want}"
);
}
}
#[test]
fn a_tie_on_size_goes_to_the_smaller() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(16, 2, 2).fill(16))
.image(ImageSpec::new(32, 2, 2).fill(32))
.build();
let cursor = Cursor::from_bytes(&bytes, 24).expect("load");
assert_eq!(cursor.first().pixels[0], 16);
}
#[test]
fn every_frame_of_the_chosen_size_is_kept() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(16, 2, 2).delay(10).fill(16))
.image(ImageSpec::new(32, 2, 2).delay(20).fill(32))
.image(ImageSpec::new(16, 2, 2).delay(30).fill(16))
.image(ImageSpec::new(32, 2, 2).delay(40).fill(32))
.build();
let small = Cursor::from_bytes(&bytes, 16).expect("load");
assert_eq!(small.frames().len(), 2);
assert!(small.frames().iter().all(|f| f.pixels[0] == 16));
assert_eq!(small.cycle(), Duration::from_millis(40));
let large = Cursor::from_bytes(&bytes, 32).expect("load");
assert_eq!(large.frames().len(), 2);
assert_eq!(large.cycle(), Duration::from_millis(60));
}
#[test]
fn a_size_of_zero_means_the_default() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 2, 2).fill(24))
.image(ImageSpec::new(64, 2, 2).fill(64))
.build();
let cursor = Cursor::from_bytes(&bytes, 0).expect("load");
assert_eq!(cursor.first().pixels[0], 64);
}
#[test]
fn pixels_are_read_as_little_endian_argb() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 1, 1).fill(0x8844_2211))
.build();
let cursor = Cursor::from_bytes(&bytes, 24).expect("load");
assert_eq!(cursor.first().pixels[0], 0x8844_2211);
}
#[test]
fn a_declared_image_larger_than_the_file_is_refused_not_allocated() {
let mut spec = ImageSpec::new(24, 512, 512);
spec.short_by = 512 * 512; let bytes = FileBuilder::new().image(spec).build();
assert!(bytes.len() < 100, "the file is tiny: {} bytes", bytes.len());
assert_eq!(
Cursor::from_bytes(&bytes, 24).unwrap_err(),
CursorError::Malformed
);
}
#[test]
fn an_oversize_image_is_refused_before_its_pixel_count_is_computed() {
let bytes = FileBuilder::new()
.image({
let mut spec = ImageSpec::new(24, 513, 1);
spec.short_by = 513;
spec
})
.build();
assert_eq!(
Cursor::from_bytes(&bytes, 24).unwrap_err(),
CursorError::TooLarge
);
}
#[test]
fn too_many_frames_is_refused() {
let mut builder = FileBuilder::new();
for _ in 0..257 {
builder = builder.image(ImageSpec::new(24, 1, 1));
}
assert_eq!(
Cursor::from_bytes(&builder.build(), 24).unwrap_err(),
CursorError::TooLarge
);
}
#[test]
fn the_frame_cap_counts_only_the_chosen_size() {
let mut builder = FileBuilder::new();
for size in [24, 32, 48, 64, 96, 128] {
for frame in 0..60 {
builder = builder.image(
ImageSpec::new(size, 1, 1)
.delay(16)
.fill(size * 100 + frame),
);
}
}
let cursor = Cursor::from_bytes(&builder.build(), 32).expect("360 images, 60 per size");
assert_eq!(cursor.frames().len(), 60);
assert_eq!(cursor.first().pixels[0], 3200, "the 32 pixel set, in order");
}
#[test]
fn adwaitas_busy_cursor_loads() {
let path = std::path::Path::new("/usr/share/icons/Adwaita/cursors/wait");
let Ok(bytes) = std::fs::read(path) else {
println!("note: skipped -- no {}", path.display());
return;
};
let cursor = Cursor::from_bytes(&bytes, 24).expect("Adwaita's wait cursor");
assert!(cursor.is_animated());
}
#[test]
fn a_toc_count_larger_than_the_file_is_refused() {
let mut builder = FileBuilder::new().image(ImageSpec::new(24, 1, 1));
builder.declared_entries = Some(u32::MAX);
assert_eq!(
Cursor::from_bytes(&builder.build(), 24).unwrap_err(),
CursorError::Malformed
);
}
#[test]
fn a_truncated_file_is_refused_at_every_length() {
let whole = FileBuilder::new()
.image(ImageSpec::new(24, 4, 4).delay(50))
.image(ImageSpec::new(24, 4, 4).delay(50))
.build();
for length in 0..whole.len() {
let result = Cursor::from_bytes(&whole[..length], 24);
assert!(
result.is_err(),
"a {length}-byte prefix of a {}-byte file must not load",
whole.len()
);
}
assert!(
Cursor::from_bytes(&whole, 24).is_ok(),
"the whole file loads"
);
}
#[test]
fn garbage_is_refused() {
for bytes in [
b"".to_vec(),
b"Xcur".to_vec(),
b"not a cursor file at all".to_vec(),
vec![0xff; 64],
] {
assert!(
Cursor::from_bytes(&bytes, 24).is_err(),
"{bytes:?} must not load"
);
}
}
#[test]
fn a_hotspot_outside_the_image_is_refused() {
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 4, 4).hot(5, 0))
.build();
assert_eq!(
Cursor::from_bytes(&bytes, 24).unwrap_err(),
CursorError::Malformed
);
}
#[test]
fn a_zero_dimension_is_refused() {
for (w, h) in [(0, 4), (4, 0)] {
let bytes = FileBuilder::new().image(ImageSpec::new(24, w, h)).build();
assert_eq!(
Cursor::from_bytes(&bytes, 24).unwrap_err(),
CursorError::Malformed,
"{w}x{h}"
);
}
}
#[test]
fn a_file_with_no_images_is_refused() {
let bytes = FileBuilder::new().build();
assert_eq!(
Cursor::from_bytes(&bytes, 24).unwrap_err(),
CursorError::NoImages
);
}
#[test]
fn from_argb_builds_a_static_cursor() {
let pixels = vec![0xff00_00ff; 16];
let cursor = Cursor::from_argb(&pixels, 4, 4, 1, 2).expect("build");
assert_eq!(cursor.frames().len(), 1);
assert_eq!((cursor.first().width, cursor.first().height), (4, 4));
assert_eq!((cursor.first().xhot, cursor.first().yhot), (1, 2));
assert_eq!(cursor.first().pixels, pixels);
assert!(!cursor.is_animated());
}
#[test]
fn from_argb_refuses_dimensions_that_do_not_describe_an_image() {
let pixels = vec![0u32; 16];
for (w, h) in [(0, 4), (4, 0), (513, 1), (1, 513)] {
assert_eq!(
Cursor::from_argb(&pixels, w, h, 0, 0).unwrap_err(),
CursorError::InvalidImage,
"{w}x{h}"
);
}
assert_eq!(
Cursor::from_argb(&pixels, 4, 5, 0, 0).unwrap_err(),
CursorError::InvalidImage
);
}
#[test]
fn loading_through_a_theme_finds_the_file() {
let fixture = Fixture::new("load-named");
let dir = fixture.theme("Adwaita", &[]);
let bytes = FileBuilder::new()
.image(ImageSpec::new(24, 2, 2).fill(0xff12_3456))
.build();
std::fs::write(dir.join("cursors").join("left_ptr"), &bytes).expect("write cursor");
let theme = fixture.discover().expect("discover");
let cursor = Cursor::load_named(&theme, "left_ptr", "Adwaita", 24).expect("load");
assert_eq!(cursor.first().pixels[0], 0xff12_3456);
}
#[test]
fn loading_a_missing_file_reports_io() {
let fixture = Fixture::new("load-missing");
let dir = fixture.theme("Adwaita", &[]);
let theme = fixture.discover().expect("discover");
std::fs::write(dir.join("cursors").join("left_ptr"), b"").expect("write");
let resolved = theme.resolve("left_ptr", "Adwaita").expect("resolve");
std::fs::remove_file(&resolved.source).expect("remove");
assert!(matches!(
Cursor::load(&resolved, 24),
Err(CursorError::Io(_))
));
}
#[test]
fn every_fuzz_seed_loads() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../fuzz/seeds/cursor_file");
if !std::path::Path::new(dir).is_dir() {
println!("note: skipped -- not running from the source tree, so there is no seed corpus");
return;
}
let mut checked = 0;
let mut animated = 0;
let mut sizes = std::collections::BTreeSet::new();
for entry in std::fs::read_dir(dir).expect("the seed corpus must exist") {
let path = entry.expect("entry").path();
let bytes = std::fs::read(&path).expect("read seed");
let cursor = Cursor::from_bytes(&bytes, 0).unwrap_or_else(|error| {
panic!("seed {} does not load: {error}", path.display());
});
if cursor.is_animated() {
animated += 1;
}
sizes.insert(cursor.first().width);
checked += 1;
}
assert!(
checked >= 4,
"expected at least four seeds, found {checked}"
);
assert!(
animated >= 1,
"no seed animates, so the frame-walking path is unseeded"
);
assert!(
sizes.len() >= 2,
"every seed is the same size, so size selection is unseeded: {sizes:?}"
);
}
use crate::Frame;
use crate::sprite::{Placement, Rotation, compose};
fn coded_frame(width: u32, height: u32, xhot: u32, yhot: u32) -> Frame {
let pixels = (0..width * height)
.map(|i| {
let (x, y) = (i % width, i / width);
0xff00_0000 | (x << 8) | y
})
.collect();
Frame {
pixels,
width,
height,
xhot,
yhot,
delay: Duration::ZERO,
}
}
fn solid_frame(width: u32, height: u32, pixel: u32) -> Frame {
Frame {
pixels: vec![pixel; (width * height) as usize],
width,
height,
xhot: 0,
yhot: 0,
delay: Duration::ZERO,
}
}
fn composed(frame: &Frame, rotation: Rotation, w: u32, h: u32) -> (Vec<u32>, Placement) {
let mut buffer = vec![0xdead_beef; (w * h) as usize];
let placement = compose(frame, rotation, &mut buffer, w, h, w as usize);
(buffer, placement)
}
fn at(buffer: &[u32], stride: u32, x: u32, y: u32) -> u32 {
buffer[(y * stride + x) as usize]
}
#[test]
fn an_unrotated_sprite_is_copied_and_centered() {
let frame = coded_frame(2, 2, 0, 0);
let (buffer, placement) = composed(&frame, Rotation::None, 4, 4);
assert_eq!((placement.width, placement.height), (2, 2));
assert_eq!(
(placement.x, placement.y),
(1, 1),
"centered in a 4x4 buffer"
);
assert_eq!(at(&buffer, 4, 1, 1), 0xff00_0000, "source (0,0)");
assert_eq!(at(&buffer, 4, 2, 1), 0xff00_0100, "source (1,0)");
assert_eq!(at(&buffer, 4, 1, 2), 0xff00_0001, "source (0,1)");
assert_eq!(at(&buffer, 4, 2, 2), 0xff00_0101, "source (1,1)");
}
#[test]
fn the_buffer_is_wiped_before_the_sprite_lands() {
let frame = solid_frame(2, 2, 0xffff_ffff);
let (buffer, _) = composed(&frame, Rotation::None, 4, 4);
let corners = [(0, 0), (3, 0), (0, 3), (3, 3)];
for (x, y) in corners {
assert_eq!(
at(&buffer, 4, x, y),
0,
"({x}, {y}) still holds what was there before"
);
}
}
#[test]
fn each_rotation_moves_pixels_where_it_should() {
let frame = coded_frame(3, 2, 0, 0);
let code = |x: u32, y: u32| 0xff00_0000 | (x << 8) | y;
let (upright, p0) = composed(&frame, Rotation::None, 3, 2);
assert_eq!((p0.width, p0.height), (3, 2));
assert_eq!(at(&upright, 3, 0, 0), code(0, 0));
assert_eq!(at(&upright, 3, 2, 1), code(2, 1));
let (quarter, p90) = composed(&frame, Rotation::Quarter, 2, 3);
assert_eq!((p90.width, p90.height), (2, 3), "a quarter turn swaps axes");
assert_eq!(at(&quarter, 2, 0, 0), code(0, 1));
assert_eq!(at(&quarter, 2, 1, 0), code(0, 0));
assert_eq!(at(&quarter, 2, 0, 2), code(2, 1));
let (half, p180) = composed(&frame, Rotation::Half, 3, 2);
assert_eq!((p180.width, p180.height), (3, 2));
assert_eq!(
at(&half, 3, 0, 0),
code(2, 1),
"half turn is a double mirror"
);
assert_eq!(at(&half, 3, 2, 1), code(0, 0));
let (three, p270) = composed(&frame, Rotation::ThreeQuarter, 2, 3);
assert_eq!((p270.width, p270.height), (2, 3));
assert_eq!(at(&three, 2, 0, 0), code(2, 0));
assert_eq!(at(&three, 2, 1, 2), code(0, 1));
}
#[test]
fn four_quarter_turns_are_the_identity() {
let frame = coded_frame(4, 4, 0, 0);
let mut current = frame.clone();
for _ in 0..4 {
let (buffer, placement) = composed(¤t, Rotation::Quarter, 4, 4);
current = Frame {
pixels: buffer,
width: placement.width,
height: placement.height,
xhot: placement.hotspot_x,
yhot: placement.hotspot_y,
delay: Duration::ZERO,
};
}
assert_eq!(
current.pixels, frame.pixels,
"four quarter turns is upright"
);
}
#[test]
fn the_hotspot_turns_with_the_pixels() {
let frame = coded_frame(4, 2, 3, 0);
let (_, none) = composed(&frame, Rotation::None, 4, 2);
assert_eq!((none.hotspot_x, none.hotspot_y), (3, 0));
let (_, quarter) = composed(&frame, Rotation::Quarter, 2, 4);
assert_eq!((quarter.hotspot_x, quarter.hotspot_y), (1, 3));
let (_, half) = composed(&frame, Rotation::Half, 4, 2);
assert_eq!((half.hotspot_x, half.hotspot_y), (0, 1));
let (_, three) = composed(&frame, Rotation::ThreeQuarter, 2, 4);
assert_eq!((three.hotspot_x, three.hotspot_y), (0, 0));
}
#[test]
fn the_hotspot_is_offset_by_the_centring() {
let frame = coded_frame(2, 2, 1, 1);
let (_, placement) = composed(&frame, Rotation::None, 8, 8);
assert_eq!((placement.x, placement.y), (3, 3));
assert_eq!(
(placement.hotspot_x, placement.hotspot_y),
(4, 4),
"the hotspot is in buffer space, not frame space"
);
}
#[test]
fn an_oversize_sprite_is_shrunk_to_fit() {
let frame = solid_frame(72, 72, 0xff40_8060);
let (buffer, placement) = composed(&frame, Rotation::None, 64, 64);
assert_eq!(
(placement.width, placement.height),
(64, 64),
"72 -> 64 must land on 64, not 63: truncating leaves a black border"
);
assert_eq!((placement.x, placement.y), (0, 0));
assert!(
buffer.iter().all(|p| *p == 0xff40_8060),
"a flat color must survive the box filter unchanged"
);
}
#[test]
fn shrinking_keeps_the_aspect_ratio() {
let frame = solid_frame(128, 32, 0xffff_ffff);
let (_, placement) = composed(&frame, Rotation::None, 64, 64);
assert_eq!((placement.width, placement.height), (64, 16));
assert_eq!((placement.x, placement.y), (0, 24), "and stays centered");
}
#[test]
fn a_shrunk_hotspot_stays_inside_the_sprite() {
let frame = coded_frame(72, 72, 71, 71);
let (_, placement) = composed(&frame, Rotation::None, 64, 64);
assert!(
placement.hotspot_x < placement.x + placement.width
&& placement.hotspot_y < placement.y + placement.height,
"hotspot ({}, {}) is outside a {}x{} sprite at ({}, {})",
placement.hotspot_x,
placement.hotspot_y,
placement.width,
placement.height,
placement.x,
placement.y
);
}
#[test]
fn rotation_and_shrink_compose() {
let frame = solid_frame(128, 32, 0xffff_ffff);
let (_, placement) = composed(&frame, Rotation::Quarter, 64, 64);
assert_eq!(
(placement.width, placement.height),
(16, 64),
"rotated to 32x128, then shrunk to fit 64x64"
);
}
#[test]
fn the_box_filter_conserves_energy() {
let mut frame = solid_frame(96, 96, 0);
for (index, pixel) in frame.pixels.iter_mut().enumerate() {
let value = u32::try_from(index % 256).expect("small");
*pixel = 0xff00_0000 | (value << 16) | (value << 8) | value;
}
let source_mean: f64 = frame
.pixels
.iter()
.map(|p| f64::from(p & 0xff))
.sum::<f64>()
/ f64::from(frame.width * frame.height);
for size in [64u32, 48, 32, 24, 17] {
let (buffer, placement) = composed(&frame, Rotation::None, size, size);
let sprite: Vec<u32> = (0..placement.height)
.flat_map(|y| (0..placement.width).map(move |x| (x, y)))
.map(|(x, y)| at(&buffer, size, placement.x + x, placement.y + y))
.collect();
let out_mean: f64 = sprite.iter().map(|p| f64::from(p & 0xff)).sum::<f64>()
/ f64::from(u32::try_from(sprite.len()).expect("small sprite"));
assert!(
(out_mean - source_mean).abs() < 2.0,
"{size}: mean moved from {source_mean:.2} to {out_mean:.2}"
);
}
}
#[test]
fn the_box_filter_never_leaves_the_sprite() {
let frame = solid_frame(100, 60, 0xffff_ffff);
let (buffer, placement) = composed(&frame, Rotation::None, 64, 64);
for y in 0..64 {
for x in 0..64 {
let inside = x >= placement.x
&& x < placement.x + placement.width
&& y >= placement.y
&& y < placement.y + placement.height;
if !inside {
assert_eq!(at(&buffer, 64, x, y), 0, "({x}, {y}) is outside the sprite");
}
}
}
}
#[test]
fn a_sprite_the_size_of_the_buffer_is_not_scaled() {
let frame = coded_frame(64, 64, 0, 0);
let (buffer, placement) = composed(&frame, Rotation::None, 64, 64);
assert_eq!((placement.width, placement.height), (64, 64));
assert_eq!((placement.x, placement.y), (0, 0));
assert_eq!(buffer, frame.pixels, "copied, not resampled");
}
#[test]
fn a_one_pixel_sprite_survives_an_extreme_shrink() {
let frame = solid_frame(512, 512, 0xffff_ffff);
let (_, placement) = composed(&frame, Rotation::None, 1, 1);
assert_eq!((placement.width, placement.height), (1, 1));
}
#[test]
fn a_stride_wider_than_the_buffer_is_respected() {
let frame = solid_frame(2, 2, 0xffff_ffff);
let (w, h, stride) = (4u32, 4u32, 8usize);
let mut buffer = vec![0u32; h as usize * stride];
let placement = compose(&frame, Rotation::None, &mut buffer, w, h, stride);
assert_eq!((placement.x, placement.y), (1, 1));
assert_eq!(buffer[stride + 1], 0xffff_ffff);
assert_eq!(buffer[2 * stride + 2], 0xffff_ffff);
for y in 0..h as usize {
for x in w as usize..stride {
assert_eq!(
buffer[y * stride + x],
0,
"padding at ({x}, {y}) was touched"
);
}
}
}
#[test]
fn the_drm_rotation_bits_are_the_uapi_ones() {
assert_eq!(Rotation::None.drm_mask(), 1);
assert_eq!(Rotation::Quarter.drm_mask(), 2);
assert_eq!(Rotation::Half.drm_mask(), 4);
assert_eq!(Rotation::ThreeQuarter.drm_mask(), 8);
assert!(Rotation::Quarter.swaps_axes() && Rotation::ThreeQuarter.swaps_axes());
assert!(!Rotation::None.swaps_axes() && !Rotation::Half.swaps_axes());
}
use crate::plane::{PlanePath, SelectedPlane, select_plane};
use drmkit_planes::{PlaneCapabilities, PlaneRegistry, PlaneType};
fn plane(id: u32, plane_type: PlaneType) -> PlaneCapabilities {
PlaneCapabilities {
id,
possible_crtcs: 0b1,
plane_type,
formats: vec![drmkit_fmt::fourcc::ARGB8888],
..PlaneCapabilities::default()
}
}
fn opaque_plane(id: u32, plane_type: PlaneType) -> PlaneCapabilities {
PlaneCapabilities {
formats: vec![drmkit_fmt::fourcc::XRGB8888],
..plane(id, plane_type)
}
}
fn registry_of(planes: Vec<PlaneCapabilities>) -> PlaneRegistry {
PlaneRegistry::from_capabilities(planes)
}
fn all_free(_: u32) -> bool {
false
}
#[test]
fn a_cursor_plane_wins() {
let registry = registry_of(vec![
plane(31, PlaneType::Primary),
plane(32, PlaneType::Overlay),
plane(33, PlaneType::Cursor),
]);
let chosen = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(chosen.plane_id, 33);
assert_eq!(chosen.path, PlanePath::AtomicCursor);
}
#[test]
fn prefer_legacy_drives_the_cursor_plane_through_the_legacy_ioctls() {
let registry = registry_of(vec![
plane(31, PlaneType::Primary),
plane(32, PlaneType::Overlay),
plane(33, PlaneType::Cursor),
]);
let chosen = select_plane(®istry, 0, None, true, true, &all_free).expect("select");
assert_eq!(chosen.path, PlanePath::Legacy);
assert_eq!(chosen.plane_id, 0, "the legacy path addresses the CRTC");
assert_eq!(chosen.legacy_plane_id, 33);
let chosen = select_plane(®istry, 0, None, false, true, &all_free).expect("select");
assert_eq!(chosen.path, PlanePath::AtomicCursor);
assert_eq!(chosen.legacy_plane_id, 0);
let chosen = select_plane(®istry, 0, Some(32), true, true, &all_free).expect("select");
assert_eq!(chosen.path, PlanePath::AtomicOverlay);
assert_eq!(chosen.plane_id, 32);
}
#[test]
fn prefer_legacy_needs_a_cursor_plane() {
let registry = registry_of(vec![
plane(31, PlaneType::Primary),
plane(32, PlaneType::Overlay),
]);
let chosen = select_plane(®istry, 0, None, true, true, &all_free).expect("select");
assert_eq!(chosen.path, PlanePath::AtomicOverlay);
assert_eq!(chosen.plane_id, 32);
}
#[test]
fn prefer_legacy_needs_a_cursor_plane_that_takes_argb8888() {
let mut cursor = plane(33, PlaneType::Cursor);
cursor.formats = vec![drmkit_fmt::fourcc::RGBA8888];
let registry = registry_of(vec![plane(31, PlaneType::Primary), cursor]);
let chosen = select_plane(®istry, 0, None, true, true, &all_free).expect("select");
assert_eq!(chosen.path, PlanePath::AtomicCursor);
assert_eq!(chosen.fourcc, drmkit_fmt::fourcc::RGBA8888);
}
#[test]
fn an_overlay_is_taken_when_there_is_no_cursor_plane() {
let registry = registry_of(vec![
plane(31, PlaneType::Primary),
plane(32, PlaneType::Overlay),
]);
let chosen = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(chosen.plane_id, 32);
assert_eq!(chosen.path, PlanePath::AtomicOverlay);
}
#[test]
fn a_cursor_plane_that_cannot_do_alpha_is_not_a_cursor_plane() {
let registry = registry_of(vec![
opaque_plane(33, PlaneType::Cursor),
plane(32, PlaneType::Overlay),
]);
let chosen = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(chosen.plane_id, 32);
assert_eq!(chosen.path, PlanePath::AtomicOverlay);
}
#[test]
fn a_free_overlay_is_preferred_to_a_busy_one() {
let registry = registry_of(vec![
plane(32, PlaneType::Overlay),
plane(34, PlaneType::Overlay),
]);
let busy = |id: u32| id == 32;
let chosen = select_plane(®istry, 0, None, true, false, &busy).expect("select");
assert_eq!(chosen.plane_id, 34, "the free overlay, not the first one");
}
#[test]
fn a_busy_overlay_beats_no_cursor_at_all() {
let registry = registry_of(vec![plane(32, PlaneType::Overlay)]);
let chosen = select_plane(®istry, 0, None, true, false, &|_| true).expect("select");
assert_eq!(chosen.plane_id, 32);
assert_eq!(chosen.path, PlanePath::AtomicOverlay);
}
#[test]
fn the_primary_plane_is_never_taken() {
let registry = registry_of(vec![plane(31, PlaneType::Primary)]);
let chosen = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(chosen.path, PlanePath::Legacy);
assert_eq!(chosen.plane_id, 0);
}
#[test]
fn legacy_is_the_last_resort_and_only_if_allowed() {
let registry = registry_of(vec![opaque_plane(32, PlaneType::Overlay)]);
assert_eq!(
select_plane(®istry, 0, None, true, false, &all_free)
.expect("select")
.path,
PlanePath::Legacy
);
assert_eq!(
select_plane(®istry, 0, None, false, false, &all_free).unwrap_err(),
CursorError::NoPlane
);
}
#[test]
fn a_plane_on_another_crtc_is_not_a_candidate() {
let mut elsewhere = plane(33, PlaneType::Cursor);
elsewhere.possible_crtcs = 0b10; let registry = registry_of(vec![elsewhere, plane(32, PlaneType::Overlay)]);
let chosen = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(chosen.plane_id, 32, "the cursor plane cannot feed CRTC 0");
}
#[test]
fn a_forced_plane_is_taken_as_asked() {
let registry = registry_of(vec![
plane(33, PlaneType::Cursor),
plane(32, PlaneType::Overlay),
]);
let chosen = select_plane(®istry, 0, Some(32), true, false, &all_free).expect("select");
assert_eq!(chosen.plane_id, 32);
assert_eq!(chosen.path, PlanePath::AtomicOverlay);
}
#[test]
fn a_forced_plane_that_will_not_work_is_refused_not_replaced() {
let registry = registry_of(vec![
plane(33, PlaneType::Cursor),
opaque_plane(32, PlaneType::Overlay),
]);
let mut wrong_crtc = plane(35, PlaneType::Overlay);
wrong_crtc.possible_crtcs = 0b10;
let registry_with = registry_of(vec![plane(33, PlaneType::Cursor), wrong_crtc]);
for (registry, forced, why) in [
(®istry, 999, "a plane that does not exist"),
(®istry, 32, "a plane that cannot do alpha"),
(®istry_with, 35, "a plane on another CRTC"),
] {
assert_eq!(
select_plane(registry, 0, Some(forced), true, false, &all_free).unwrap_err(),
CursorError::PlaneUnusable,
"{why}"
);
}
}
#[test]
fn a_forced_plane_does_not_fall_back_to_legacy_either() {
let registry = registry_of(vec![plane(33, PlaneType::Cursor)]);
assert_eq!(
select_plane(®istry, 0, Some(999), true, false, &all_free).unwrap_err(),
CursorError::PlaneUnusable
);
}
#[test]
fn the_cursor_plane_reports_its_size_limit() {
let mut cursor = plane(33, PlaneType::Cursor);
cursor.cursor_max_w = 64;
cursor.cursor_max_h = 64;
let registry = registry_of(vec![cursor, plane(32, PlaneType::Overlay)]);
let chosen = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(
(chosen.cursor_max_w, chosen.cursor_max_h),
(64, 64),
"the cursor plane's own maximum"
);
let overlay = select_plane(®istry, 0, Some(32), true, false, &all_free).expect("select");
assert_eq!(
(overlay.cursor_max_w, overlay.cursor_max_h),
(0, 0),
"an overlay is bounded by the mode, not by a cursor limit"
);
}
#[test]
fn an_empty_registry_falls_back() {
let registry = registry_of(Vec::new());
assert_eq!(
select_plane(®istry, 0, None, true, false, &all_free)
.expect("select")
.path,
PlanePath::Legacy
);
assert_eq!(
select_plane(®istry, 0, None, false, false, &all_free).unwrap_err(),
CursorError::NoPlane
);
}
#[test]
fn the_selection_is_a_value_worth_comparing() {
let registry = registry_of(vec![plane(33, PlaneType::Cursor)]);
let a = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
let b = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(a, b, "the same registry gives the same answer");
assert_eq!(
a,
SelectedPlane {
plane_id: 33,
path: PlanePath::AtomicCursor,
cursor_max_w: 0,
cursor_max_h: 0,
fourcc: drmkit_fmt::fourcc::ARGB8888,
legacy_plane_id: 0,
}
);
}
#[test]
fn a_cursor_plane_offering_only_rgba_is_used_rather_than_spending_an_overlay() {
let cursor = PlaneCapabilities {
formats: vec![drmkit_fmt::fourcc::RGBA8888],
..plane(43, PlaneType::Cursor)
};
let registry = registry_of(vec![cursor, plane(44, PlaneType::Overlay)]);
let selected = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(
selected.path,
PlanePath::AtomicCursor,
"the dedicated hardware, not the overlay next to it"
);
assert_eq!(selected.plane_id, 43);
assert_eq!(
selected.fourcc,
drmkit_fmt::fourcc::RGBA8888,
"and the buffer is allocated in what the plane will take"
);
}
#[test]
fn argb_is_preferred_so_the_common_case_copies_the_shadow_unchanged() {
let both = PlaneCapabilities {
formats: vec![drmkit_fmt::fourcc::RGBA8888, drmkit_fmt::fourcc::ARGB8888],
..plane(50, PlaneType::Cursor)
};
let registry = registry_of(vec![both]);
let selected = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(selected.fourcc, drmkit_fmt::fourcc::ARGB8888);
}
#[test]
fn widening_the_format_list_did_not_let_an_opaque_plane_through() {
let registry = registry_of(vec![opaque_plane(60, PlaneType::Cursor)]);
let selected = select_plane(®istry, 0, None, true, false, &all_free).expect("select");
assert_eq!(
selected.path,
PlanePath::Legacy,
"XRGB8888 carries no alpha, so the cursor would be a rectangle"
);
}
#[test]
fn each_cursor_format_lays_its_channels_down_in_the_right_order() {
use crate::renderer::pack;
let pixel = 0x11_22_33_44_u32;
let (a, r, g, b) = (0x11, 0x22, 0x33, 0x44);
assert_eq!(
pack(pixel, drmkit_fmt::fourcc::ARGB8888).to_le_bytes(),
[b, g, r, a],
"ARGB8888"
);
assert_eq!(
pack(pixel, drmkit_fmt::fourcc::RGBA8888).to_le_bytes(),
[a, b, g, r],
"RGBA8888, which is what Tegra's cursor plane wants"
);
assert_eq!(
pack(pixel, drmkit_fmt::fourcc::ABGR8888).to_le_bytes(),
[r, g, b, a],
"ABGR8888"
);
assert_eq!(
pack(pixel, drmkit_fmt::fourcc::BGRA8888).to_le_bytes(),
[a, r, g, b],
"BGRA8888"
);
for fourcc in crate::CURSOR_FORMATS {
let packed = pack(pixel, fourcc);
assert_eq!(
packed.count_ones(),
pixel.count_ones(),
"a permutation moves bits without inventing or dropping any"
);
}
}
use crate::size::{preferred_size, probe_size};
#[test]
fn a_named_size_is_taken_as_asked() {
for path in [
PlanePath::AtomicCursor,
PlanePath::AtomicOverlay,
PlanePath::Legacy,
] {
assert_eq!(preferred_size(128, path, Some(256)), 128, "{path:?}");
}
}
#[test]
fn an_unset_size_takes_the_drivers_cap_on_the_atomic_paths() {
assert_eq!(preferred_size(0, PlanePath::AtomicCursor, Some(256)), 256);
assert_eq!(preferred_size(0, PlanePath::AtomicOverlay, Some(128)), 128);
}
#[test]
fn legacy_ignores_the_cap() {
assert_eq!(preferred_size(0, PlanePath::Legacy, Some(256)), 64);
}
#[test]
fn a_missing_or_zero_cap_falls_back_to_64() {
assert_eq!(preferred_size(0, PlanePath::AtomicCursor, None), 64);
assert_eq!(preferred_size(0, PlanePath::AtomicCursor, Some(0)), 64);
}
#[test]
fn the_probe_takes_the_largest_size_the_kernel_accepts() {
let mut asked = Vec::new();
let size = probe_size(256, |candidate| {
asked.push(candidate);
candidate <= 128
});
assert_eq!(size, 128);
assert_eq!(
asked,
vec![256, 128],
"largest first, stopping at the first yes"
);
}
#[test]
fn the_probe_never_goes_above_what_was_asked_for() {
let mut asked = Vec::new();
probe_size(64, |candidate| {
asked.push(candidate);
true
});
assert_eq!(asked, vec![64], "256 and 128 are above the preference");
}
#[test]
fn a_size_between_ladder_steps_probes_the_step_below() {
let mut asked = Vec::new();
let size = probe_size(100, |candidate| {
asked.push(candidate);
true
});
assert_eq!(asked, vec![64]);
assert_eq!(size, 64);
}
#[test]
fn a_size_below_the_floor_still_gets_a_buffer() {
let mut asked = Vec::new();
let size = probe_size(32, |candidate| {
asked.push(candidate);
true
});
assert!(asked.is_empty(), "nothing on the ladder fits under 32");
assert_eq!(size, 64);
}
#[test]
fn a_kernel_that_refuses_everything_still_yields_a_size() {
let mut asked = Vec::new();
let size = probe_size(256, |candidate| {
asked.push(candidate);
false
});
assert_eq!(asked, vec![256, 128, 64], "every candidate was tried");
assert_eq!(size, 256, "falls back to what was asked for");
assert_eq!(probe_size(16, |_| false), 64);
}
#[test]
fn the_probe_stops_asking_once_something_is_accepted() {
let mut calls = 0;
let size = probe_size(256, |_| {
calls += 1;
true
});
assert_eq!(calls, 1);
assert_eq!(size, 256);
}
use crate::stage::{hardware_rotation, plane_origin};
#[test]
fn the_plane_is_offset_back_by_the_hotspot() {
assert_eq!(plane_origin((100, 100), (0, 0)), (100, 100));
assert_eq!(plane_origin((100, 100), (4, 7)), (96, 93));
}
#[test]
fn a_cursor_near_the_top_left_hangs_off_the_edge() {
assert_eq!(plane_origin((1, 1), (4, 4)), (-3, -3));
assert_eq!(plane_origin((0, 0), (31, 31)), (-31, -31));
}
#[test]
fn the_origin_does_not_wrap_at_the_extremes() {
assert_eq!(plane_origin((i32::MIN, i32::MIN), (64, 64)).0, i32::MIN);
assert_eq!(plane_origin((i32::MAX, 0), (0, 0)).0, i32::MAX);
}
#[test]
fn hardware_rotation_is_used_when_the_plane_has_it() {
let all = Rotation::None.drm_mask()
| Rotation::Quarter.drm_mask()
| Rotation::Half.drm_mask()
| Rotation::ThreeQuarter.drm_mask();
for rotation in [
Rotation::None,
Rotation::Quarter,
Rotation::Half,
Rotation::ThreeQuarter,
] {
assert_eq!(
hardware_rotation(rotation, all),
rotation.drm_mask(),
"{rotation:?}"
);
}
}
#[test]
fn an_unsupported_rotation_asks_the_hardware_for_none() {
let half_only = Rotation::None.drm_mask() | Rotation::Half.drm_mask();
assert_eq!(
hardware_rotation(Rotation::Half, half_only),
Rotation::Half.drm_mask(),
"180 is in the mask, so the hardware does it"
);
for software in [Rotation::Quarter, Rotation::ThreeQuarter] {
assert_eq!(
hardware_rotation(software, half_only),
Rotation::None.drm_mask(),
"{software:?} is not in the mask, so the pixels were already turned"
);
}
}
#[test]
fn a_plane_with_no_rotation_support_asks_for_none() {
for rotation in [Rotation::None, Rotation::Quarter, Rotation::Half] {
assert_eq!(
hardware_rotation(rotation, 0),
Rotation::None.drm_mask(),
"{rotation:?}"
);
}
}
use crate::pingpong::PingPong;
#[test]
fn a_fresh_pair_draws_into_the_one_not_on_screen() {
let buffers = PingPong::new();
assert_eq!(buffers.active(), 0);
assert_eq!(buffers.draw_target(), 1, "never the one being scanned out");
assert_eq!(
buffers.commit_target(),
0,
"nothing drawn yet, so publish what is already there"
);
}
#[test]
fn a_draw_then_a_commit_swaps_which_is_live() {
let mut buffers = PingPong::new();
buffers.mark_drawn();
assert_eq!(
buffers.commit_target(),
1,
"publish what was just drawn, not the stale one"
);
buffers.published();
assert_eq!(buffers.active(), 1, "the published buffer is now live");
assert_eq!(buffers.draw_target(), 0, "and the next draw goes elsewhere");
assert!(!buffers.has_undrawn());
}
#[test]
fn drawing_never_targets_the_buffer_on_screen() {
let mut buffers = PingPong::new();
for step in 0..32 {
assert_ne!(
buffers.draw_target(),
buffers.active(),
"step {step}: drawing into the live buffer"
);
if step % 3 != 0 {
buffers.mark_drawn();
}
if step % 2 == 0 {
buffers.published();
}
}
}
#[test]
fn a_move_only_commit_republishes_what_is_on_screen() {
let mut buffers = PingPong::new();
buffers.mark_drawn();
buffers.published();
assert_eq!(buffers.active(), 1);
for _ in 0..4 {
assert_eq!(buffers.commit_target(), 1, "the buffer already on screen");
buffers.published();
assert_eq!(buffers.active(), 1, "and nothing swaps");
}
}
#[test]
fn several_draws_before_one_commit_publish_once() {
let mut buffers = PingPong::new();
for _ in 0..5 {
assert_eq!(buffers.draw_target(), 1);
buffers.mark_drawn();
}
assert_eq!(buffers.commit_target(), 1);
buffers.published();
assert_eq!(buffers.active(), 1);
assert!(!buffers.has_undrawn());
}
#[test]
fn a_commit_with_nothing_drawn_changes_nothing() {
let mut buffers = PingPong::new();
let before = buffers;
buffers.published();
assert_eq!(buffers, before, "no draw, no flip");
}
#[test]
fn a_single_buffer_has_nowhere_else_to_go() {
let mut buffers = PingPong::single();
assert_eq!(buffers.draw_target(), 0);
assert_eq!(buffers.commit_target(), 0);
buffers.mark_drawn();
assert!(
!buffers.has_undrawn(),
"a single buffer is never waiting to be published"
);
assert_eq!(buffers.commit_target(), 0);
buffers.published();
assert_eq!(buffers.active(), 0, "there is nothing to swap with");
assert_eq!(buffers.draw_target(), 0);
}
#[test]
fn the_pair_returns_to_where_it_started() {
let mut buffers = PingPong::new();
for _ in 0..2 {
buffers.mark_drawn();
buffers.published();
}
assert_eq!(buffers, PingPong::new());
}