use crate::{Color, ColorError, Theme, ThemeError, glass_default, glass_lite, glass_minimal};
use crate::{load_theme_file, load_theme_str};
use drmkit_fmt::fourcc::XRGB8888;
static CARD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn card_guard() -> std::sync::MutexGuard<'static, ()> {
CARD_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn open_card() -> Option<drmkit_core::Device> {
let path = std::env::var("DRMKIT_TEST_CARD").unwrap_or_else(|_| "/dev/dri/card0".to_owned());
drmkit_testkit::announce_card(&path);
match drmkit_core::Device::open(&path) {
Ok(device) => Some(device),
Err(error) => {
assert!(
std::env::var_os("DRMKIT_REQUIRE_MASTER").is_none(),
"{path}: {error}, but DRMKIT_REQUIRE_MASTER is set"
);
drmkit_testkit::skipped(&format!("no DRM device at {path} ({error})"));
None
}
}
}
#[test]
fn six_hex_digits_are_an_opaque_colour() {
assert_eq!(
Color::from_hex("#123456"),
Ok(Color::new(0x12, 0x34, 0x56, 0xFF))
);
}
#[test]
fn eight_hex_digits_carry_their_own_alpha() {
assert_eq!(
Color::from_hex("#12345678"),
Ok(Color::new(0x12, 0x34, 0x56, 0x78))
);
}
#[test]
fn hex_digits_are_case_insensitive() {
assert_eq!(
Color::from_hex("#aabbccdd"),
Color::from_hex("#AABBCCDD"),
"the same colour written two ways is the same colour"
);
assert_eq!(
Color::from_hex("#AaBbCcDd"),
Ok(Color::new(0xAA, 0xBB, 0xCC, 0xDD))
);
}
#[test]
fn malformed_colours_are_refused_by_what_they_were() {
for bad in [
"", "123456", "#12345", "#1234567", "#123456789", "#12345g", "#GGGGGG", ] {
assert_eq!(
Color::from_hex(bad),
Err(ColorError::Malformed {
got: bad.to_owned()
}),
"{bad:?} should not parse"
);
}
}
#[test]
fn packing_is_alpha_red_green_blue() {
assert_eq!(
Color::new(0x12, 0x34, 0x56, 0x78).packed_argb(),
0x7812_3456
);
assert_eq!(
Color::new(0xFF, 0x00, 0x00, 0xFF).packed_argb(),
0xFFFF_0000,
"opaque red, not opaque blue"
);
}
#[test]
fn colour_equality_is_every_channel() {
let base = Color::new(1, 2, 3, 4);
assert_eq!(base, Color::new(1, 2, 3, 4));
for other in [
Color::new(9, 2, 3, 4),
Color::new(1, 9, 3, 4),
Color::new(1, 2, 9, 4),
Color::new(1, 2, 3, 9),
] {
assert_ne!(base, other, "a channel apart is a different colour");
}
}
const COMPLETE: &str = r##"
name = "round-trip-glass"
corner_radius = 12
noise_amplitude = 0.06
shadow_extent = 32
animation_duration_ms = 220
[title_bar]
height = 30
font = "Cantarell, sans-serif"
font_size = 14
[colors]
panel_top = "#FFFFFFAA"
panel_bottom = "#FFFFFF22"
rim_focused = "#AAAAAA88"
rim_blurred = "#BBBBBB44"
shadow = "#0000007F"
title_text = "#11111111"
title_shadow = "#FFFFFFAA"
[buttons.close]
fill = "#FF0000FF"
hover = "#FF6666FF"
[buttons.minimize]
fill = "#FFFF00FF"
hover = "#FFFF99FF"
[buttons.maximize]
fill = "#00FF00FF"
hover = "#99FF99FF"
"##;
#[test]
fn a_complete_theme_round_trips() {
let theme = load_theme_str(COMPLETE, Theme::default()).expect("valid TOML");
assert_eq!(theme.name, "round-trip-glass");
assert_eq!(theme.corner_radius, 12);
assert!((theme.noise_amplitude - 0.06).abs() < f64::EPSILON);
assert_eq!(theme.shadow_extent, 32);
assert_eq!(theme.animation_duration_ms, 220);
assert_eq!(theme.title_bar.height, 30);
assert_eq!(theme.title_bar.font, "Cantarell, sans-serif");
assert_eq!(theme.title_bar.font_size, 14);
assert_eq!(theme.colors.panel_top, Color::new(0xFF, 0xFF, 0xFF, 0xAA));
assert_eq!(theme.colors.shadow, Color::new(0x00, 0x00, 0x00, 0x7F));
assert_eq!(theme.colors.title_text, Color::new(0x11, 0x11, 0x11, 0x11));
assert_eq!(theme.buttons.close.fill, Color::new(0xFF, 0x00, 0x00, 0xFF));
assert_eq!(
theme.buttons.maximize.hover,
Color::new(0x99, 0xFF, 0x99, 0xFF)
);
}
#[test]
fn absent_fields_inherit_from_the_base() {
let theme = load_theme_str(r#"name = "just-a-name""#, glass_default()).expect("valid TOML");
let base = glass_default();
assert_eq!(theme.name, "just-a-name", "what was said is taken");
assert_eq!(
theme.corner_radius, base.corner_radius,
"and what was not is inherited"
);
assert_eq!(theme.title_bar, base.title_bar);
assert_eq!(theme.colors, base.colors);
assert_eq!(theme.buttons, base.buttons);
}
#[test]
fn a_partial_table_inherits_its_siblings() {
let theme = load_theme_str("[colors]\npanel_top = \"#01020304\"\n", glass_default())
.expect("valid TOML");
let base = glass_default();
assert_eq!(theme.colors.panel_top, Color::new(1, 2, 3, 4));
assert_eq!(
theme.colors.shadow, base.colors.shadow,
"the siblings of an overridden colour are not collateral"
);
assert_eq!(theme.colors.title_text, base.colors.title_text);
}
#[test]
fn text_that_is_not_toml_is_a_syntax_error() {
let error =
load_theme_str("this is not = = toml", Theme::default()).expect_err("that is not TOML");
assert!(matches!(error, ThemeError::Syntax(_)), "got {error}");
}
#[test]
fn a_bad_colour_names_the_field_it_was_in() {
let error = load_theme_str("[colors]\nshadow = \"not-a-colour\"\n", Theme::default())
.expect_err("that is not a colour");
match error {
ThemeError::BadColor { field, source } => {
assert_eq!(field, "shadow");
assert_eq!(
source,
ColorError::Malformed {
got: "not-a-colour".to_owned()
}
);
}
other => panic!("got {other}, which does not say which field"),
}
}
#[test]
fn a_colour_that_is_not_a_string_names_the_field() {
let error = load_theme_str("[colors]\nshadow = 42\n", Theme::default())
.expect_err("a number is not a colour");
match error {
ThemeError::WrongType { field, expected } => {
assert_eq!(field, "shadow");
assert!(expected.contains("RRGGBB"), "expected {expected:?} to help");
}
other => panic!("got {other}"),
}
}
#[test]
fn a_missing_theme_file_says_which_file() {
let error = load_theme_file("/nonexistent/theme.toml", Theme::default())
.expect_err("there is no such file");
match error {
ThemeError::Io { path, source } => {
assert_eq!(path, "/nonexistent/theme.toml");
assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
}
other => panic!("got {other}"),
}
}
#[test]
fn the_default_theme_is_named_and_populated() {
let theme = glass_default();
assert_eq!(theme.name, "glass-default");
assert!(theme.corner_radius > 0);
assert!(theme.title_bar.height > 0);
assert!(!theme.title_bar.font.is_empty());
assert!(
theme.colors.panel_top.a > 0,
"a panel nobody can see is not one"
);
assert!(theme.buttons.close.fill.a > 0);
}
#[test]
fn the_lite_theme_is_cheaper_than_the_default() {
let lite = glass_lite();
let base = glass_default();
assert_eq!(lite.name, "glass-lite");
assert!(lite.shadow_extent < base.shadow_extent);
assert!(lite.animation_duration_ms < base.animation_duration_ms);
assert!(lite.noise_amplitude < base.noise_amplitude);
}
#[test]
fn the_minimal_theme_drops_the_shadow_and_the_animation() {
let theme = glass_minimal();
assert_eq!(theme.name, "glass-minimal");
assert_eq!(theme.shadow_extent, 0);
assert_eq!(theme.animation_duration_ms, 0);
assert!((theme.noise_amplitude - 0.0).abs() < f64::EPSILON);
assert_eq!(
theme.colors.shadow.a, 0,
"the colour goes with the extent, or raising one brings back the other"
);
assert!(
theme.title_bar.height > 0,
"minimal is not undecorated -- there is still a title bar"
);
}
#[test]
fn a_nested_button_colour_is_read_and_reported_by_different_names() {
let theme = load_theme_str("[buttons.close]\nfill = \"#010203FF\"\n", glass_default())
.expect("valid TOML");
assert_eq!(
theme.buttons.close.fill,
Color::new(1, 2, 3, 0xFF),
"the key inside the table is `fill`; looking up the qualified name \
finds nothing and silently inherits"
);
assert_eq!(
theme.buttons.close.hover,
glass_default().buttons.close.hover,
"and its sibling still inherits, which is why the miss is silent"
);
let error = load_theme_str("[buttons.close]\nfill = \"nope\"\n", glass_default())
.expect_err("that is not a colour");
match error {
ThemeError::BadColor { field, .. } => assert_eq!(field, "buttons.close.fill"),
other => panic!("got {other}"),
}
}
use crate::decoration_geometry;
#[test]
fn the_panel_is_inset_by_the_shadow_on_every_side() {
let theme = glass_default();
let geometry = decoration_geometry(&theme, 600, 360);
assert_eq!(geometry.panel_x, theme.shadow_extent);
assert_eq!(geometry.panel_y, theme.shadow_extent);
assert_eq!(geometry.panel_w, 600 - 2 * theme.shadow_extent);
assert_eq!(geometry.panel_h, 360 - 2 * theme.shadow_extent);
}
#[test]
fn a_theme_with_no_shadow_fills_the_canvas() {
let geometry = decoration_geometry(&glass_minimal(), 600, 360);
assert_eq!((geometry.panel_x, geometry.panel_y), (0, 0));
assert_eq!((geometry.panel_w, geometry.panel_h), (600, 360));
}
#[test]
fn a_decoration_too_small_for_its_shadow_clamps_to_a_zero_panel() {
let geometry = decoration_geometry(&glass_default(), 30, 30);
assert_eq!(geometry.panel_w, 0);
assert_eq!(geometry.panel_h, 0);
assert!(
geometry.panel_w >= 0 && geometry.panel_h >= 0,
"clamped, not wrapped"
);
}
#[test]
fn a_negative_shadow_extent_is_treated_as_none() {
let theme = Theme {
shadow_extent: -20,
..glass_default()
};
let geometry = decoration_geometry(&theme, 600, 360);
assert_eq!((geometry.panel_x, geometry.panel_y), (0, 0));
assert_eq!((geometry.panel_w, geometry.panel_h), (600, 360));
}
#[test]
fn the_buttons_run_right_to_left_evenly_spaced() {
let geometry = decoration_geometry(&glass_default(), 600, 360);
assert!(geometry.close_cx > geometry.minimize_cx);
assert!(geometry.minimize_cx > geometry.maximize_cx);
assert_eq!(
geometry.close_cx - geometry.minimize_cx,
geometry.minimize_cx - geometry.maximize_cx,
"evenly spaced, or the row looks like a mistake"
);
}
#[test]
fn every_button_lands_inside_the_title_bar() {
let theme = glass_default();
let geometry = decoration_geometry(&theme, 600, 360);
for (name, cx) in [
("close", geometry.close_cx),
("minimize", geometry.minimize_cx),
("maximize", geometry.maximize_cx),
] {
assert!(
cx - geometry.button_radius >= geometry.panel_x,
"{name} crosses the panel's left edge"
);
assert!(
cx + geometry.button_radius < geometry.panel_x + geometry.panel_w,
"{name} crosses the panel's right edge"
);
}
assert!(
geometry.button_cy - geometry.button_radius >= geometry.panel_y,
"the row crosses the top of the panel"
);
assert!(
geometry.button_cy + geometry.button_radius < geometry.panel_y + geometry.title_bar_height,
"the row hangs below the title bar and into the window's content"
);
}
#[test]
fn a_panel_too_narrow_for_the_buttons_still_answers() {
let geometry = decoration_geometry(&glass_default(), 60, 360);
assert_eq!(geometry.panel_w, 60 - 2 * glass_default().shadow_extent);
assert!(
geometry.maximize_cx < geometry.panel_x,
"the leftmost button does not fit, which is exactly what a caller \
checking the bounds needs to be able to see"
);
}
use crate::{DEFAULT_CAPACITY, Elevation, ShadowCache, ShadowDest, ShadowKey, theme_id};
fn key(width: u32, height: u32, elevation: Elevation, theme: &Theme) -> ShadowKey {
ShadowKey {
width,
height,
elevation,
theme_id: theme_id(theme),
}
}
fn dest(width: u32, height: u32) -> (Vec<u8>, u32) {
(vec![0u8; (width * height * 4) as usize], width * 4)
}
#[test]
fn a_theme_id_is_stable_across_rebuilds() {
assert_eq!(theme_id(&glass_default()), theme_id(&glass_default()));
assert_eq!(theme_id(&glass_minimal()), theme_id(&glass_minimal()));
}
#[test]
fn a_theme_id_changes_when_a_colour_does() {
let base = glass_default();
let changed = Theme {
colors: crate::Colors {
shadow: Color::new(1, 2, 3, 4),
..base.colors
},
..base.clone()
};
assert_ne!(
theme_id(&base),
theme_id(&changed),
"a cached shadow drawn in the old colour would outlive the change"
);
}
#[test]
fn a_theme_id_changes_when_the_shadow_extent_does() {
let base = glass_default();
let changed = Theme {
shadow_extent: base.shadow_extent + 1,
..base.clone()
};
assert_ne!(theme_id(&base), theme_id(&changed));
}
#[test]
fn a_theme_id_ignores_the_name() {
let base = glass_default();
let renamed = Theme {
name: "something-else".to_owned(),
..base.clone()
};
assert_eq!(theme_id(&base), theme_id(&renamed));
}
#[test]
fn a_theme_id_ignores_the_animation_duration() {
let base = glass_default();
let quicker = Theme {
animation_duration_ms: base.animation_duration_ms / 2,
..base.clone()
};
assert_eq!(
theme_id(&base),
theme_id(&quicker),
"how long a shadow takes to fade cannot change what it looks like"
);
}
#[test]
fn a_zero_capacity_cache_uses_the_default() {
assert_eq!(ShadowCache::new(0).capacity(), DEFAULT_CAPACITY);
assert_eq!(ShadowCache::default().capacity(), DEFAULT_CAPACITY);
}
#[test]
fn an_explicit_capacity_is_respected() {
assert_eq!(ShadowCache::new(3).capacity(), 3);
}
#[test]
fn clearing_empties_the_cache() {
let theme = glass_default();
let mut cache = ShadowCache::new(4);
let (mut pixels, stride) = dest(64, 64);
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 64,
height: 64,
};
assert!(cache.blit_into(key(64, 64, Elevation::Focused, &theme), &theme, &mut target));
assert_eq!(cache.len(), 1);
cache.clear();
assert!(cache.is_empty());
assert!(!cache.contains(&key(64, 64, Elevation::Focused, &theme)));
}
#[test]
fn a_zero_sized_shadow_writes_nothing() {
let theme = glass_default();
let mut cache = ShadowCache::new(4);
let (mut pixels, stride) = dest(64, 64);
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 64,
height: 64,
};
assert!(!cache.blit_into(key(0, 64, Elevation::Focused, &theme), &theme, &mut target));
assert!(!cache.blit_into(key(64, 0, Elevation::Focused, &theme), &theme, &mut target));
assert!(cache.is_empty(), "and nothing was cached either");
}
#[test]
fn a_zero_sized_destination_writes_nothing() {
let theme = glass_default();
let mut cache = ShadowCache::new(4);
let (mut pixels, stride) = dest(64, 64);
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 0,
height: 64,
};
assert!(!cache.blit_into(key(64, 64, Elevation::Focused, &theme), &theme, &mut target));
}
#[test]
fn the_first_blit_renders_a_shadow_with_visible_alpha() {
let theme = glass_default();
let mut cache = ShadowCache::new(4);
let (mut pixels, stride) = dest(96, 96);
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 96,
height: 96,
};
assert!(cache.blit_into(key(96, 96, Elevation::Focused, &theme), &theme, &mut target));
assert_eq!(cache.len(), 1);
let centre = ((48 * 96) + 48) * 4;
assert!(
pixels[centre + 3] > 0,
"the middle of a shadow patch is where it is strongest; zero there \
means nothing was drawn"
);
}
#[test]
fn a_second_blit_of_the_same_shadow_is_a_hit() {
let theme = glass_default();
let mut cache = ShadowCache::new(4);
let (mut pixels, stride) = dest(64, 64);
let shadow = key(64, 64, Elevation::Focused, &theme);
for _ in 0..3 {
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 64,
height: 64,
};
assert!(cache.blit_into(shadow, &theme, &mut target));
}
assert_eq!(cache.len(), 1, "three blits of one shadow render it once");
}
#[test]
fn focused_and_blurred_are_separate_entries() {
let theme = glass_default();
let mut cache = ShadowCache::new(4);
let (mut focused_px, stride) = dest(64, 64);
let (mut blurred_px, _) = dest(64, 64);
let mut target = ShadowDest {
pixels: &mut focused_px,
stride,
width: 64,
height: 64,
};
cache.blit_into(key(64, 64, Elevation::Focused, &theme), &theme, &mut target);
let mut target = ShadowDest {
pixels: &mut blurred_px,
stride,
width: 64,
height: 64,
};
cache.blit_into(key(64, 64, Elevation::Blurred, &theme), &theme, &mut target);
assert_eq!(cache.len(), 2, "two elevations, two shadows");
assert_ne!(
focused_px, blurred_px,
"and they must actually differ, or the elevation says nothing"
);
let centre = ((32 * 64) + 32) * 4 + 3;
assert!(
focused_px[centre] > blurred_px[centre],
"the focused shadow is the stronger one"
);
}
#[test]
fn the_least_recently_used_shadow_is_evicted() {
let theme = glass_default();
let mut cache = ShadowCache::new(2);
let (mut pixels, stride) = dest(64, 64);
for size in [32u32, 48, 64] {
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 64,
height: 64,
};
cache.blit_into(
key(size, size, Elevation::Focused, &theme),
&theme,
&mut target,
);
}
assert_eq!(cache.len(), 2, "the cap holds");
assert!(
!cache.contains(&key(32, 32, Elevation::Focused, &theme)),
"the oldest went"
);
assert!(cache.contains(&key(64, 64, Elevation::Focused, &theme)));
}
#[test]
fn using_a_shadow_again_keeps_it_from_eviction() {
let theme = glass_default();
let mut cache = ShadowCache::new(2);
let (mut pixels, stride) = dest(64, 64);
let blit = |cache: &mut ShadowCache, size: u32, pixels: &mut Vec<u8>| {
let mut target = ShadowDest {
pixels,
stride,
width: 64,
height: 64,
};
cache.blit_into(
key(size, size, Elevation::Focused, &theme),
&theme,
&mut target,
);
};
blit(&mut cache, 32, &mut pixels);
blit(&mut cache, 48, &mut pixels);
blit(&mut cache, 32, &mut pixels);
blit(&mut cache, 64, &mut pixels);
assert!(
cache.contains(&key(32, 32, Elevation::Focused, &theme)),
"32 was used most recently before the eviction, so it stays"
);
assert!(
!cache.contains(&key(48, 48, Elevation::Focused, &theme)),
"48 was the stalest"
);
}
#[test]
fn a_theme_with_no_shadow_produces_nothing_visible() {
let theme = glass_minimal();
let mut cache = ShadowCache::new(2);
let (mut pixels, stride) = dest(64, 64);
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 64,
height: 64,
};
assert!(cache.blit_into(key(64, 64, Elevation::Focused, &theme), &theme, &mut target));
assert!(
pixels.iter().all(|byte| *byte == 0),
"a zero-alpha shadow colour must write nothing visible, whatever the \
extent says"
);
}
#[test]
fn a_destination_smaller_than_the_patch_is_clipped() {
let theme = glass_default();
let mut cache = ShadowCache::new(2);
let (mut pixels, stride) = dest(32, 32);
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 32,
height: 32,
};
assert!(cache.blit_into(key(96, 96, Elevation::Focused, &theme), &theme, &mut target));
assert_eq!(pixels.len(), 32 * 32 * 4, "nothing grew the destination");
}
#[test]
fn a_cross_fade_blends_between_its_endpoints() {
let theme = glass_default();
let mut cache = ShadowCache::new(4);
let focused = key(64, 64, Elevation::Focused, &theme);
let blurred = key(64, 64, Elevation::Blurred, &theme);
let centre = ((32 * 64) + 32) * 4 + 3;
let sample = |cache: &mut ShadowCache, t: f32| {
let (mut pixels, stride) = dest(64, 64);
let mut target = ShadowDest {
pixels: &mut pixels,
stride,
width: 64,
height: 64,
};
assert!(cache.blit_cross_fade(focused, blurred, &theme, &mut target, t));
pixels[centre]
};
let at_start = sample(&mut cache, 0.0);
let midway = sample(&mut cache, 0.5);
let at_end = sample(&mut cache, 1.0);
assert!(at_start > at_end, "focused is the stronger endpoint");
assert!(
midway < at_start && midway > at_end,
"halfway is between them, not at one of them"
);
assert_eq!(
sample(&mut cache, -5.0),
at_start,
"clamped, not extrapolated"
);
assert_eq!(sample(&mut cache, 5.0), at_end);
}
use crate::{Dirty, HoverButton, PROGRESS_UNSET, WindowAnim, WindowState, ease_out_cubic};
use std::time::Duration;
const MS: fn(u64) -> Duration = Duration::from_millis;
#[test]
fn the_ease_hits_both_endpoints_exactly() {
assert!((ease_out_cubic(0.0) - 0.0).abs() < f32::EPSILON);
assert!((ease_out_cubic(1.0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn the_ease_clamps_rather_than_extrapolating() {
assert!((ease_out_cubic(-5.0) - 0.0).abs() < f32::EPSILON);
assert!((ease_out_cubic(-0.001) - 0.0).abs() < f32::EPSILON);
assert!((ease_out_cubic(5.0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn the_ease_never_goes_backwards() {
let mut previous = ease_out_cubic(0.0);
for step in 1..=100u8 {
let current = ease_out_cubic(f32::from(step) / 100.0);
assert!(
current >= previous,
"eased backwards at {step}: {current} < {previous}"
);
previous = current;
}
}
#[test]
fn the_ease_is_fast_at_the_start() {
assert!(
ease_out_cubic(0.25) > 0.5,
"a quarter of the way through time should be past halfway in value"
);
assert!(ease_out_cubic(0.5) > 0.8);
}
#[test]
fn a_new_animator_is_settled_not_animating() {
let focused = WindowAnim::new(true);
assert!((focused.focus_progress() - 1.0).abs() < f32::EPSILON);
assert!(!focused.is_animating());
let blurred = WindowAnim::new(false);
assert!((blurred.focus_progress() - 0.0).abs() < f32::EPSILON);
assert!(!blurred.is_animating());
}
#[test]
fn retargeting_to_the_same_focus_does_nothing() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(false);
assert!(!anim.is_animating());
anim.retarget_focus(true);
assert!(anim.is_animating());
anim.tick(MS(50), MS(200));
let midway = anim.focus_progress();
anim.retarget_focus(true);
assert!(
(anim.focus_progress() - midway).abs() < f32::EPSILON,
"a redundant retarget must not restart the run"
);
}
#[test]
fn retargeting_focus_starts_a_run() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(true);
assert!(anim.is_animating());
assert!(
(anim.focus_progress() - 0.0).abs() < f32::EPSILON,
"and does not jump -- the run has not been ticked yet"
);
}
#[test]
fn a_full_duration_reaches_the_target() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(true);
let still_going = anim.tick(MS(200), MS(200));
assert!((anim.focus_progress() - 1.0).abs() < f32::EPSILON);
assert!(!still_going, "and reports that it has finished");
assert!(!anim.is_animating());
}
#[test]
fn a_tick_past_the_duration_clamps_to_the_target() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(true);
anim.tick(MS(10_000), MS(200));
assert!(
(anim.focus_progress() - 1.0).abs() < f32::EPSILON,
"a dropped frame must not carry the window past focused"
);
}
#[test]
fn progress_only_increases_across_a_run() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(true);
let mut previous = anim.focus_progress();
for _ in 0..20 {
anim.tick(MS(10), MS(200));
assert!(
anim.focus_progress() >= previous,
"went backwards mid-run: {} < {previous}",
anim.focus_progress()
);
previous = anim.focus_progress();
}
}
#[test]
fn retargeting_mid_flight_eases_from_where_it_is() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(true);
anim.tick(MS(100), MS(200));
let midway = anim.focus_progress();
assert!(midway > 0.0 && midway < 1.0, "genuinely mid-flight");
anim.retarget_focus(false);
assert!(
(anim.focus_progress() - midway).abs() < f32::EPSILON,
"the retarget itself must not move the value"
);
anim.tick(MS(10), MS(200));
assert!(
anim.focus_progress() < midway,
"and it heads back down from there rather than snapping to zero"
);
}
#[test]
fn nothing_is_hovered_to_begin_with() {
let anim = WindowAnim::default();
assert_eq!(anim.hover_painted(), HoverButton::None);
assert!((anim.hover_progress() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn entering_a_button_runs_from_zero_to_one() {
let mut anim = WindowAnim::default();
anim.retarget_hover(HoverButton::Close);
assert_eq!(anim.hover_painted(), HoverButton::Close);
assert!((anim.hover_progress() - 0.0).abs() < f32::EPSILON);
anim.tick(MS(200), MS(200));
assert!((anim.hover_progress() - 1.0).abs() < f32::EPSILON);
assert_eq!(anim.hover_painted(), HoverButton::Close);
}
#[test]
fn leaving_fades_the_button_that_was_left() {
let mut anim = WindowAnim::default();
anim.retarget_hover(HoverButton::Minimize);
anim.tick(MS(200), MS(200));
assert!((anim.hover_progress() - 1.0).abs() < f32::EPSILON);
anim.retarget_hover(HoverButton::None);
assert_eq!(
anim.hover_painted(),
HoverButton::Minimize,
"still drawn, because it is still fading"
);
anim.tick(MS(100), MS(200));
assert!(anim.hover_progress() < 1.0, "and fading");
assert_eq!(anim.hover_painted(), HoverButton::Minimize);
anim.tick(MS(200), MS(200));
assert!((anim.hover_progress() - 0.0).abs() < f32::EPSILON);
assert_eq!(
anim.hover_painted(),
HoverButton::None,
"once faded out, it stops being drawn at all"
);
}
#[test]
fn moving_between_buttons_restarts_on_the_new_one() {
let mut anim = WindowAnim::default();
anim.retarget_hover(HoverButton::Close);
anim.tick(MS(200), MS(200));
assert!((anim.hover_progress() - 1.0).abs() < f32::EPSILON);
anim.retarget_hover(HoverButton::Maximize);
assert_eq!(anim.hover_painted(), HoverButton::Maximize);
assert!(
(anim.hover_progress() - 0.0).abs() < f32::EPSILON,
"the new button lights from nothing, not from the old one's value"
);
}
#[test]
fn snapping_jumps_to_the_targets() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(true);
anim.retarget_hover(HoverButton::Close);
assert!(anim.is_animating());
anim.snap();
assert!((anim.focus_progress() - 1.0).abs() < f32::EPSILON);
assert!((anim.hover_progress() - 1.0).abs() < f32::EPSILON);
assert_eq!(anim.hover_painted(), HoverButton::Close);
assert!(!anim.is_animating());
}
#[test]
fn a_zero_duration_snaps() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(true);
let still_going = anim.tick(MS(16), Duration::ZERO);
assert!((anim.focus_progress() - 1.0).abs() < f32::EPSILON);
assert!(!still_going);
assert!(!anim.is_animating());
}
#[test]
fn applying_mirrors_the_progress_into_the_state() {
let mut anim = WindowAnim::new(false);
anim.retarget_focus(true);
anim.retarget_hover(HoverButton::Minimize);
anim.tick(MS(100), MS(200));
let mut state = WindowState::default();
assert!((state.focus_progress - PROGRESS_UNSET).abs() < f32::EPSILON);
anim.apply_to(&mut state);
assert!((state.focus_progress - anim.focus_progress()).abs() < f32::EPSILON);
assert!((state.hover_progress - anim.hover_progress()).abs() < f32::EPSILON);
assert_eq!(state.hover, HoverButton::Minimize);
}
#[test]
fn a_fresh_state_is_dirty_and_the_bits_are_separable() {
let state = WindowState::default();
assert!(state.dirty.intersects(Dirty::GEOMETRY));
assert!(state.dirty.intersects(Dirty::HOVER));
assert!(Dirty::NONE.is_empty());
assert!(
!Dirty::HOVER.intersects(Dirty::GEOMETRY),
"one bit is not another"
);
assert!(
Dirty::HOVER.union(Dirty::FOCUS).intersects(Dirty::FOCUS),
"and a union carries both"
);
}
use crate::{OverlayReservation, ReserveError};
use drmkit_planes::{PlaneCapabilities, PlaneRegistry, PlaneType};
fn overlay(id: u32, crtcs: u32, zpos: Option<u64>) -> PlaneCapabilities {
PlaneCapabilities {
id,
possible_crtcs: crtcs,
plane_type: PlaneType::Overlay,
formats: vec![XRGB8888],
zpos_min: zpos,
zpos_max: zpos,
..PlaneCapabilities::default()
}
}
fn partitioned() -> PlaneRegistry {
PlaneRegistry::from_capabilities(vec![
overlay(10, 0b01, Some(1)),
overlay(11, 0b01, Some(2)),
overlay(20, 0b10, Some(1)),
overlay(21, 0b10, Some(2)),
])
}
#[test]
fn each_crtc_reserves_without_touching_the_other() {
let registry = partitioned();
let mut reservation = OverlayReservation::new();
let first = reservation
.reserve(®istry, 0, XRGB8888, 2, 0)
.expect("two overlays on CRTC 0");
let second = reservation
.reserve(®istry, 1, XRGB8888, 2, 0)
.expect("two on CRTC 1, which the first reservation cannot have taken");
assert_eq!(first, vec![10, 11]);
assert_eq!(second, vec![20, 21]);
assert_eq!(reservation.all_reserved(), vec![10, 11, 20, 21]);
}
#[test]
fn asking_for_more_planes_than_exist_is_a_shortfall() {
let registry = partitioned();
let mut reservation = OverlayReservation::new();
let error = reservation
.reserve(®istry, 0, XRGB8888, 3, 0)
.expect_err("CRTC 0 has two overlays");
assert_eq!(
error,
ReserveError::Shortfall {
crtc_index: 0,
wanted: 3,
found: 2
}
);
assert!(
reservation.all_reserved().is_empty(),
"a shortfall must not leave planes claimed that nobody is using"
);
}
#[test]
fn a_shared_plane_goes_to_whoever_claims_it_first() {
let registry = PlaneRegistry::from_capabilities(vec![
overlay(30, 0b11, Some(1)),
overlay(31, 0b11, Some(2)),
]);
let mut reservation = OverlayReservation::new();
let first = reservation
.reserve(®istry, 0, XRGB8888, 2, 0)
.expect("CRTC 0 takes the pool");
assert_eq!(first, vec![30, 31]);
let error = reservation
.reserve(®istry, 1, XRGB8888, 1, 0)
.expect_err("there is nothing left");
assert_eq!(
error,
ReserveError::Shortfall {
crtc_index: 1,
wanted: 1,
found: 0
}
);
}
#[test]
fn releasing_frees_the_planes_for_another_crtc() {
let registry = PlaneRegistry::from_capabilities(vec![overlay(30, 0b11, Some(1))]);
let mut reservation = OverlayReservation::new();
reservation
.reserve(®istry, 0, XRGB8888, 1, 0)
.expect("CRTC 0 takes it");
assert!(reservation.reserve(®istry, 1, XRGB8888, 1, 0).is_err());
reservation.release(0);
assert_eq!(
reservation.reserve(®istry, 1, XRGB8888, 1, 0),
Ok(vec![30]),
"released means available"
);
assert!(reservation.reserved_for(0).is_empty());
}
#[test]
fn releasing_is_idempotent() {
let registry = partitioned();
let mut reservation = OverlayReservation::new();
reservation
.reserve(®istry, 0, XRGB8888, 1, 0)
.expect("one overlay");
reservation.release(0);
reservation.release(0);
reservation.release(99);
assert!(reservation.all_reserved().is_empty());
}
#[test]
fn reserving_again_replaces_the_previous_claim() {
let registry = partitioned();
let mut reservation = OverlayReservation::new();
reservation
.reserve(®istry, 0, XRGB8888, 2, 0)
.expect("both");
let narrowed = reservation
.reserve(®istry, 0, XRGB8888, 1, 0)
.expect("now just one");
assert_eq!(narrowed.len(), 1);
assert_eq!(
reservation.all_reserved(),
narrowed,
"the plane it gave up is available again, not still claimed"
);
}
#[test]
fn a_zpos_floor_excludes_planes_below_it() {
let registry = PlaneRegistry::from_capabilities(vec![
overlay(10, 0b01, Some(1)),
overlay(11, 0b01, Some(5)),
]);
let mut reservation = OverlayReservation::new();
let claimed = reservation
.reserve(®istry, 0, XRGB8888, 1, 4)
.expect("one plane sits at or above 4");
assert_eq!(claimed, vec![11], "not the one at zpos 1");
}
#[test]
fn a_plane_that_cannot_take_the_format_is_not_a_candidate() {
let mut wrong_format = overlay(11, 0b01, Some(2));
wrong_format.formats = vec![drmkit_fmt::fourcc::NV12];
let registry = PlaneRegistry::from_capabilities(vec![overlay(10, 0b01, Some(1)), wrong_format]);
let mut reservation = OverlayReservation::new();
let error = reservation
.reserve(®istry, 0, XRGB8888, 2, 0)
.expect_err("only one plane takes XRGB8888");
assert_eq!(
error,
ReserveError::Shortfall {
crtc_index: 0,
wanted: 2,
found: 1
}
);
}
#[test]
fn a_plane_without_a_zpos_is_skipped_when_a_floor_is_required() {
let registry = PlaneRegistry::from_capabilities(vec![overlay(10, 0b01, None)]);
let mut reservation = OverlayReservation::new();
assert!(
reservation.reserve(®istry, 0, XRGB8888, 1, 1).is_err(),
"it cannot be shown to be above zpos 1"
);
}
#[test]
fn a_plane_without_a_zpos_is_admitted_when_no_floor_is_required() {
let registry = PlaneRegistry::from_capabilities(vec![overlay(10, 0b01, None)]);
let mut reservation = OverlayReservation::new();
assert_eq!(
reservation.reserve(®istry, 0, XRGB8888, 1, 0),
Ok(vec![10])
);
}
#[test]
fn reserved_planes_come_back_in_a_stable_zpos_order() {
let registry = PlaneRegistry::from_capabilities(vec![
overlay(30, 0b01, Some(5)),
overlay(10, 0b01, Some(1)),
overlay(20, 0b01, Some(1)),
]);
let mut reservation = OverlayReservation::new();
let claimed = reservation
.reserve(®istry, 0, XRGB8888, 3, 0)
.expect("all three");
assert_eq!(
claimed,
vec![10, 20, 30],
"zpos first, then id -- the tie has to break the same way every time"
);
assert_eq!(reservation.reserved_for(0), claimed.as_slice());
}
#[test]
fn reserving_nothing_succeeds() {
let registry = partitioned();
let mut reservation = OverlayReservation::new();
assert_eq!(
reservation.reserve(®istry, 0, XRGB8888, 0, 0),
Ok(Vec::new())
);
assert!(reservation.reserved_for(0).is_empty());
assert!(reservation.all_reserved().is_empty());
}
#[test]
fn a_crtc_that_never_reserved_holds_nothing() {
let reservation = OverlayReservation::new();
assert!(reservation.reserved_for(0).is_empty());
assert!(reservation.all_reserved().is_empty());
}
#[test]
fn a_primary_plane_is_never_reserved() {
let mut primary = overlay(10, 0b01, Some(0));
primary.plane_type = PlaneType::Primary;
let registry = PlaneRegistry::from_capabilities(vec![primary, overlay(11, 0b01, Some(1))]);
let mut reservation = OverlayReservation::new();
let claimed = reservation
.reserve(®istry, 0, XRGB8888, 1, 0)
.expect("the overlay");
assert_eq!(claimed, vec![11], "the primary is not on offer");
}
use crate::{Canvas, DrawError, Renderer, RendererConfig};
const W: u32 = 240;
const H: u32 = 160;
fn canvas() -> Vec<u8> {
vec![0u8; (W * H * 4) as usize]
}
fn render(theme: &Theme, state: &WindowState) -> Vec<u8> {
let mut pixels = canvas();
let mut shadows = ShadowCache::new(4);
let mut target = Canvas {
pixels: &mut pixels,
stride: W * 4,
width: W,
height: H,
};
Renderer::default()
.draw(theme, state, &mut target, &mut shadows)
.expect("a 240x160 canvas is drawable");
pixels
}
fn alpha_at(pixels: &[u8], x: u32, y: u32) -> u8 {
pixels[((y * W + x) * 4 + 3) as usize]
}
#[test]
fn a_renderer_builds_and_reports_no_font() {
let renderer = Renderer::default();
assert!(!renderer.has_font());
assert_eq!(renderer.font_path(), None);
let configured = Renderer::new(RendererConfig {
font_path: Some("/usr/share/fonts/x.ttf".to_owned()),
});
assert_eq!(configured.font_path(), Some("/usr/share/fonts/x.ttf"));
assert!(
!configured.has_font(),
"a path is not a loaded font, and saying otherwise would be a lie a \
caller lays out against"
);
}
#[test]
fn an_empty_canvas_is_refused() {
let mut shadows = ShadowCache::new(2);
let mut nothing: Vec<u8> = Vec::new();
let mut target = Canvas {
pixels: &mut nothing,
stride: 0,
width: 0,
height: 0,
};
assert_eq!(
Renderer::default().draw(
&glass_default(),
&WindowState::default(),
&mut target,
&mut shadows
),
Err(DrawError::EmptyCanvas)
);
}
#[test]
fn a_canvas_shorter_than_it_claims_is_refused() {
let mut shadows = ShadowCache::new(2);
let mut pixels = vec![0u8; 16];
let mut target = Canvas {
pixels: &mut pixels,
stride: W * 4,
width: W,
height: H,
};
let error = Renderer::default()
.draw(
&glass_default(),
&WindowState::default(),
&mut target,
&mut shadows,
)
.expect_err("sixteen bytes is not a 240x160 canvas");
assert!(
matches!(error, DrawError::Undersized { got: 16, .. }),
"got {error}, which does not say what was wrong with the canvas"
);
}
#[test]
fn the_middle_of_the_panel_is_painted() {
let state = WindowState {
title: "test".to_owned(),
focused: true,
..WindowState::default()
};
let pixels = render(&glass_default(), &state);
assert!(
alpha_at(&pixels, W / 2, H / 2) > 0,
"the centre of a decoration is inside its panel; zero alpha there \
means nothing was drawn at all"
);
}
#[test]
fn the_far_corner_stays_transparent() {
let state = WindowState {
focused: true,
..WindowState::default()
};
let pixels = render(&glass_default(), &state);
assert_eq!(
alpha_at(&pixels, 0, 0),
0,
"the top-left of the canvas is beyond the shadow's reach"
);
}
#[test]
fn drawing_is_deterministic() {
let state = WindowState {
focused: true,
..WindowState::default()
};
let first = render(&glass_default(), &state);
let second = render(&glass_default(), &state);
assert_eq!(
first, second,
"two draws of one decoration must be byte-identical, or every \
redraw-skipping optimisation above this is unsound"
);
}
#[test]
fn a_focused_decoration_differs_from_a_blurred_one() {
let theme = glass_default();
let focused = render(
&theme,
&WindowState {
focused: true,
..WindowState::default()
},
);
let blurred = render(
&theme,
&WindowState {
focused: false,
..WindowState::default()
},
);
assert_ne!(focused, blurred, "the two must not look the same");
let unshadowed = glass_minimal();
let focused = render(
&unshadowed,
&WindowState {
focused: true,
..WindowState::default()
},
);
let blurred = render(
&unshadowed,
&WindowState {
focused: false,
..WindowState::default()
},
);
let differs_at_rim = (0..W).any(|x| {
let offset = (x * 4) as usize;
focused[offset..offset + 4] != blurred[offset..offset + 4]
});
assert!(
differs_at_rim,
"the rim is the cue that says where keystrokes are going; it has to \
be the thing that changes, not the shadow behind it"
);
}
#[test]
fn hovering_a_button_changes_that_button_and_no_other() {
let theme = glass_default();
let geometry = decoration_geometry(&theme, W, H);
let plain = render(&theme, &WindowState::default());
let hovered = render(
&theme,
&WindowState {
hover: HoverButton::Close,
hover_progress: 1.0,
..WindowState::default()
},
);
let at = |pixels: &[u8], cx: i32| {
let x = u32::try_from(cx).expect("on canvas");
let y = u32::try_from(geometry.button_cy).expect("on canvas");
let offset = ((y * W + x) * 4) as usize;
pixels[offset..offset + 4].to_vec()
};
assert_ne!(
at(&plain, geometry.close_cx),
at(&hovered, geometry.close_cx),
"the hovered button has to light, or hover means nothing"
);
assert_eq!(
at(&plain, geometry.minimize_cx),
at(&hovered, geometry.minimize_cx),
"and only that one -- lighting every button on any hover would be \
worse than lighting none"
);
}
#[test]
fn drawing_populates_the_shadow_cache() {
let mut pixels = canvas();
let mut shadows = ShadowCache::new(4);
assert!(shadows.is_empty());
let mut target = Canvas {
pixels: &mut pixels,
stride: W * 4,
width: W,
height: H,
};
Renderer::default()
.draw(
&glass_default(),
&WindowState {
focused: true,
..WindowState::default()
},
&mut target,
&mut shadows,
)
.expect("drawable");
assert_eq!(shadows.len(), 1, "the shadow went through the cache");
}
#[test]
fn a_theme_with_no_shadow_still_draws_its_panel() {
let pixels = render(
&glass_minimal(),
&WindowState {
focused: true,
..WindowState::default()
},
);
assert!(alpha_at(&pixels, W / 2, H / 2) > 0, "the panel is there");
assert!(
alpha_at(&pixels, 0, H / 2) > 0,
"and with no shadow inset it reaches the canvas edge -- sampled at \
mid-height, because the panel is still rounded and the literal \
corner is outside it"
);
assert_eq!(
alpha_at(&pixels, 0, 0),
0,
"the corner is clear even with no shadow, because the panel is rounded"
);
}
#[test]
fn a_decoration_too_small_for_its_shadow_draws_nothing() {
let theme = glass_default();
let mut pixels = vec![0u8; (16 * 16 * 4) as usize];
let mut shadows = ShadowCache::new(2);
let mut target = Canvas {
pixels: &mut pixels,
stride: 16 * 4,
width: 16,
height: 16,
};
Renderer::default()
.draw(&theme, &WindowState::default(), &mut target, &mut shadows)
.expect("a zero panel is not an error");
}
use crate::{
PlaneError, PlaneSlot, PropertyWrite, SurfaceRef, Tier, choose_presenter_tier, compute_writes,
};
#[test]
fn enough_planes_means_a_plane_each() {
assert_eq!(choose_presenter_tier(3, 3, true), Some(Tier::Plane));
assert_eq!(
choose_presenter_tier(5, 3, false),
Some(Tier::Plane),
"with a plane each, no canvas is needed"
);
}
#[test]
fn too_few_planes_with_a_canvas_means_compositing() {
assert_eq!(choose_presenter_tier(1, 3, true), Some(Tier::Composite));
assert_eq!(choose_presenter_tier(0, 1, true), Some(Tier::Composite));
}
#[test]
fn no_planes_and_no_canvas_chooses_nothing() {
assert_eq!(choose_presenter_tier(0, 1, false), None);
assert_eq!(choose_presenter_tier(1, 3, false), None);
}
#[test]
fn wanting_no_decorations_is_never_the_plane_tier() {
assert_eq!(choose_presenter_tier(4, 0, true), Some(Tier::Composite));
assert_eq!(choose_presenter_tier(4, 0, false), None);
}
fn slot(plane_id: u32) -> PlaneSlot {
PlaneSlot {
plane_id,
crtc_id: 99,
fb_id_prop: 1,
crtc_id_prop: 2,
crtc_x_prop: 3,
crtc_y_prop: 4,
crtc_w_prop: 5,
crtc_h_prop: 6,
src_x_prop: 7,
src_y_prop: 8,
src_w_prop: 9,
src_h_prop: 10,
..PlaneSlot::default()
}
}
fn surface(fb_id: u32, x: i32, y: i32) -> SurfaceRef {
SurfaceRef {
fb_id,
width: 64,
height: 32,
x,
y,
generation: 0,
}
}
fn value_of(writes: &[PropertyWrite], object_id: u32, property_id: u32) -> Option<u64> {
writes
.iter()
.find(|w| w.object_id == object_id && w.property_id == property_id)
.map(|w| w.value)
}
#[test]
fn more_decorations_than_planes_is_refused() {
let error = compute_writes(&[slot(10)], &[surface(1, 0, 0), surface(2, 0, 0)])
.expect_err("two decorations, one plane");
assert_eq!(
error,
PlaneError::TooManySurfaces {
surfaces: 2,
slots: 1
}
);
}
#[test]
fn no_slots_and_no_surfaces_writes_nothing() {
assert_eq!(compute_writes(&[], &[]), Ok(Vec::new()));
}
#[test]
fn an_armed_slot_gets_its_geometry() {
let writes = compute_writes(&[slot(10)], &[surface(77, 5, 9)]).expect("one each");
assert_eq!(value_of(&writes, 10, 1), Some(77), "FB_ID");
assert_eq!(value_of(&writes, 10, 2), Some(99), "CRTC_ID");
assert_eq!(value_of(&writes, 10, 3), Some(5), "CRTC_X");
assert_eq!(value_of(&writes, 10, 4), Some(9), "CRTC_Y");
assert_eq!(value_of(&writes, 10, 5), Some(64), "CRTC_W");
assert_eq!(value_of(&writes, 10, 6), Some(32), "CRTC_H");
assert_eq!(
value_of(&writes, 10, 9),
Some(64 << 16),
"SRC_W is 16.16 fixed point, not pixels"
);
assert_eq!(value_of(&writes, 10, 10), Some(32 << 16), "SRC_H likewise");
}
#[test]
fn a_surface_with_no_framebuffer_disarms_its_slot() {
let writes = compute_writes(&[slot(10)], &[surface(0, 0, 0)]).expect("one slot");
assert_eq!(value_of(&writes, 10, 1), Some(0), "FB_ID cleared");
assert_eq!(
value_of(&writes, 10, 2),
Some(0),
"and unbound from the CRTC"
);
assert_eq!(
writes.len(),
2,
"and nothing else -- describing geometry for a framebuffer that is \
not there says nothing"
);
}
#[test]
fn a_zero_sized_surface_disarms_its_slot() {
let empty = SurfaceRef {
fb_id: 7,
width: 0,
height: 32,
..SurfaceRef::default()
};
let writes = compute_writes(&[slot(10)], &[empty]).expect("one slot");
assert_eq!(writes.len(), 2, "disarmed, not drawn at zero width");
assert_eq!(value_of(&writes, 10, 1), Some(0));
}
#[test]
fn slots_past_the_surfaces_are_disarmed() {
let writes = compute_writes(&[slot(10), slot(20), slot(30)], &[surface(77, 0, 0)])
.expect("one of three");
assert_eq!(value_of(&writes, 10, 1), Some(77), "the first is armed");
assert_eq!(value_of(&writes, 20, 1), Some(0), "the tail is not");
assert_eq!(value_of(&writes, 30, 1), Some(0));
assert_eq!(value_of(&writes, 30, 2), Some(0));
}
#[test]
fn the_optional_properties_are_written_when_present() {
let with_extras = PlaneSlot {
blend_mode_prop: 20,
blend_mode_value: 3,
alpha_prop: 21,
zpos_prop: 22,
zpos_value: 7,
..slot(10)
};
let writes = compute_writes(&[with_extras], &[surface(77, 0, 0)]).expect("one slot");
assert_eq!(value_of(&writes, 10, 20), Some(3), "blend mode");
assert_eq!(
value_of(&writes, 10, 21),
Some(0xFFFF),
"fully opaque -- the decoration's own alpha is in its pixels, and a \
per-plane alpha on top would fade the whole thing"
);
assert_eq!(value_of(&writes, 10, 22), Some(7), "zpos");
}
#[test]
fn a_property_the_plane_lacks_is_not_written() {
let writes = compute_writes(&[slot(10)], &[surface(77, 0, 0)]).expect("one slot");
assert!(
!writes.iter().any(|w| w.property_id == 0),
"a write to property zero would take down every layer in the commit, \
not just this one"
);
assert_eq!(
writes.len(),
10,
"the ten mandatory properties, and no more"
);
}
#[test]
fn a_negative_position_is_carried_through() {
let writes = compute_writes(&[slot(10)], &[surface(77, -4, -8)]).expect("one slot");
assert_eq!(
value_of(&writes, 10, 3),
Some(i64::from(-4).cast_unsigned())
);
assert_eq!(
value_of(&writes, 10, 4),
Some(i64::from(-8).cast_unsigned())
);
}
#[test]
fn a_default_surface_reference_is_not_armed() {
assert!(!SurfaceRef::default().is_armed());
assert!(surface(1, 0, 0).is_armed());
}
use crate::{DamageRect, DamageSlot, compute_damage, intersect_rect, union_rect};
const CANVAS_W: u32 = 200;
const CANVAS_H: u32 = 100;
fn armed(x: i32, y: i32, w: u32, h: u32, generation: u64) -> DamageSlot {
DamageSlot {
armed: true,
x,
y,
w,
h,
generation,
}
}
fn damage(prev: &[DamageSlot], cur: &[DamageSlot]) -> DamageRect {
compute_damage(prev, cur, CANVAS_W, CANVAS_H)
}
#[test]
fn the_first_frame_damages_the_whole_canvas() {
let region = damage(&[], &[armed(0, 0, 10, 10, 1)]);
assert_eq!(
region,
DamageRect {
x: 0,
y: 0,
w: CANVAS_W,
h: CANVAS_H
}
);
}
#[test]
fn a_change_in_slot_count_damages_the_whole_canvas() {
let before = [armed(0, 0, 10, 10, 1)];
let after = [armed(0, 0, 10, 10, 1), armed(50, 50, 10, 10, 1)];
assert_eq!(damage(&before, &after).w, CANVAS_W);
assert_eq!(damage(&after, &before).w, CANVAS_W);
}
#[test]
fn nothing_changing_damages_nothing() {
let slots = [armed(10, 10, 20, 20, 5), armed(80, 40, 30, 30, 9)];
assert!(damage(&slots, &slots).is_empty());
}
#[test]
fn a_move_damages_the_old_footprint_and_the_new() {
let before = [armed(10, 10, 20, 20, 1)];
let after = [armed(50, 10, 20, 20, 1)];
let region = damage(&before, &after);
assert_eq!(region.x, 10, "starts at the old left edge");
assert_eq!(region.w, 60, "and reaches the new right edge");
assert_eq!(region.y, 10);
assert_eq!(region.h, 20);
}
#[test]
fn a_repaint_in_place_damages_its_slot() {
let before = [armed(10, 10, 20, 20, 1)];
let after = [armed(10, 10, 20, 20, 2)];
assert_eq!(
damage(&before, &after),
DamageRect {
x: 10,
y: 10,
w: 20,
h: 20
}
);
}
#[test]
fn opening_damages_the_new_rectangle_and_closing_the_old() {
let empty = [DamageSlot::default()];
let present = [armed(30, 20, 40, 25, 1)];
let opened = damage(&empty, &present);
assert_eq!(
opened,
DamageRect {
x: 30,
y: 20,
w: 40,
h: 25
}
);
let closed = damage(&present, &empty);
assert_eq!(
closed, opened,
"the same region has to be repainted either way"
);
}
#[test]
fn damage_is_clamped_to_the_canvas() {
let before = [DamageSlot::default()];
let after = [armed(-20, -10, 60, 40, 1)];
let region = damage(&before, &after);
assert_eq!(region.x, 0, "not negative");
assert_eq!(region.y, 0);
assert_eq!(region.w, 40, "and only the part that is on the canvas");
assert_eq!(region.h, 30);
}
#[test]
fn a_decoration_entirely_off_the_canvas_damages_nothing() {
let before = [DamageSlot::default()];
let after = [armed(500, 500, 10, 10, 1)];
assert!(damage(&before, &after).is_empty());
}
#[test]
fn a_union_covers_both_and_ignores_an_empty_one() {
let a = DamageRect {
x: 0,
y: 0,
w: 10,
h: 10,
};
let b = DamageRect {
x: 90,
y: 40,
w: 10,
h: 10,
};
let both = union_rect(a, b);
assert_eq!(both.x, 0);
assert_eq!(both.y, 0);
assert_eq!(both.w, 100);
assert_eq!(both.h, 50);
assert_eq!(union_rect(a, DamageRect::default()), a);
assert_eq!(union_rect(DamageRect::default(), b), b);
}
#[test]
fn an_intersection_is_the_overlap() {
let region = DamageRect {
x: 10,
y: 10,
w: 50,
h: 50,
};
assert_eq!(
intersect_rect(0, 0, 20, 20, region),
DamageRect {
x: 10,
y: 10,
w: 10,
h: 10
}
);
assert!(
intersect_rect(100, 100, 10, 10, region).is_empty(),
"disjoint rectangles overlap in nothing"
);
}
use crate::{BlitItem, FbTarget, compose_into_framebuffer, fb_fourcc_for};
use drmkit_fmt::fourcc;
#[test]
fn thirty_two_bpp_with_red_above_blue_is_rgb_ordered() {
assert_eq!(fb_fourcc_for(32, 16, 0, 0), Some(fourcc::XRGB8888));
assert_eq!(
fb_fourcc_for(32, 16, 0, 8),
Some(fourcc::ARGB8888),
"a transparency field means the fourth byte is alpha, not padding"
);
}
#[test]
fn thirty_two_bpp_with_red_below_blue_is_bgr_ordered() {
assert_eq!(fb_fourcc_for(32, 0, 16, 0), Some(fourcc::XBGR8888));
assert_eq!(fb_fourcc_for(32, 0, 16, 8), Some(fourcc::ABGR8888));
}
#[test]
fn sixteen_bpp_is_565() {
assert_eq!(fb_fourcc_for(16, 11, 0, 0), Some(fourcc::RGB565));
assert_eq!(fb_fourcc_for(16, 0, 11, 0), Some(fourcc::BGR565));
}
#[test]
fn a_depth_that_cannot_be_described_is_refused() {
assert_eq!(fb_fourcc_for(24, 16, 0, 0), None);
assert_eq!(fb_fourcc_for(8, 0, 0, 0), None);
assert_eq!(fb_fourcc_for(0, 0, 0, 0), None);
}
const FB_W: u32 = 8;
const FB_H: u32 = 4;
fn one_pixel(argb: [u8; 4]) -> Vec<u8> {
argb.to_vec()
}
#[test]
fn one_opaque_pixel_lands_at_its_offset() {
let pixel = one_pixel([0x10, 0x20, 0x30, 0xFF]); let items = [BlitItem {
pixels: &pixel,
stride: 4,
width: 1,
height: 1,
fourcc: fourcc::ARGB8888,
x: 3,
y: 2,
}];
let mut framebuffer = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut shadow = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut target = FbTarget {
pixels: &mut framebuffer,
stride: FB_W * 4,
width: FB_W,
height: FB_H,
fourcc: fourcc::XRGB8888,
};
compose_into_framebuffer(
&mut target,
&mut shadow,
&items,
DamageRect {
x: 0,
y: 0,
w: FB_W,
h: FB_H,
},
);
let at = ((2 * FB_W + 3) * 4) as usize;
assert_eq!(
&framebuffer[at..at + 3],
&[0x10, 0x20, 0x30],
"the pixel is where it was placed, in the destination's own order"
);
assert_eq!(&framebuffer[0..4], &[0, 0, 0, 0], "and nowhere else");
}
#[test]
fn a_bgr_destination_has_its_channels_swapped() {
let pixel = one_pixel([0x10, 0x20, 0x30, 0xFF]);
let items = [BlitItem {
pixels: &pixel,
stride: 4,
width: 1,
height: 1,
fourcc: fourcc::ARGB8888,
x: 0,
y: 0,
}];
let mut framebuffer = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut shadow = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut target = FbTarget {
pixels: &mut framebuffer,
stride: FB_W * 4,
width: FB_W,
height: FB_H,
fourcc: fourcc::XBGR8888,
};
compose_into_framebuffer(
&mut target,
&mut shadow,
&items,
DamageRect {
x: 0,
y: 0,
w: FB_W,
h: FB_H,
},
);
assert_eq!(
&framebuffer[0..3],
&[0x30, 0x20, 0x10],
"red and blue exchanged relative to the ARGB source"
);
}
#[test]
fn a_shadow_too_small_for_the_framebuffer_does_nothing() {
let pixel = one_pixel([0xFF, 0xFF, 0xFF, 0xFF]);
let items = [BlitItem {
pixels: &pixel,
stride: 4,
width: 1,
height: 1,
fourcc: fourcc::ARGB8888,
x: 0,
y: 0,
}];
let mut framebuffer = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut shadow = vec![0u8; 16];
let mut target = FbTarget {
pixels: &mut framebuffer,
stride: FB_W * 4,
width: FB_W,
height: FB_H,
fourcc: fourcc::XRGB8888,
};
compose_into_framebuffer(
&mut target,
&mut shadow,
&items,
DamageRect {
x: 0,
y: 0,
w: FB_W,
h: FB_H,
},
);
assert!(framebuffer.iter().all(|b| *b == 0), "nothing was written");
}
#[test]
fn only_the_damaged_rows_are_converted() {
let pixels = vec![0xFFu8; (FB_W * FB_H * 4) as usize];
let items = [BlitItem {
pixels: &pixels,
stride: FB_W * 4,
width: FB_W,
height: FB_H,
fourcc: fourcc::ARGB8888,
x: 0,
y: 0,
}];
let mut framebuffer = vec![0xAAu8; (FB_W * FB_H * 4) as usize];
let mut shadow = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut target = FbTarget {
pixels: &mut framebuffer,
stride: FB_W * 4,
width: FB_W,
height: FB_H,
fourcc: fourcc::XRGB8888,
};
compose_into_framebuffer(
&mut target,
&mut shadow,
&items,
DamageRect {
x: 0,
y: 1,
w: FB_W,
h: 1,
},
);
let row = |n: u32| {
let start = (n * FB_W * 4) as usize;
framebuffer[start..start + (FB_W * 4) as usize].to_vec()
};
assert!(
row(1).iter().any(|b| *b != 0),
"the damaged row was written"
);
assert!(
row(0).iter().all(|b| *b == 0xAA),
"the row above still holds what was already on screen -- converting \
it would paint the shadow's empty pixels over the rest of the display"
);
assert!(row(3).iter().all(|b| *b == 0xAA), "and the row below");
}
#[test]
fn empty_damage_composites_nothing() {
let pixels = vec![0xFFu8; (FB_W * FB_H * 4) as usize];
let items = [BlitItem {
pixels: &pixels,
stride: FB_W * 4,
width: FB_W,
height: FB_H,
fourcc: fourcc::ARGB8888,
x: 0,
y: 0,
}];
let mut framebuffer = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut shadow = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut target = FbTarget {
pixels: &mut framebuffer,
stride: FB_W * 4,
width: FB_W,
height: FB_H,
fourcc: fourcc::XRGB8888,
};
compose_into_framebuffer(&mut target, &mut shadow, &items, DamageRect::default());
assert!(
framebuffer.iter().all(|b| *b == 0),
"a frame where nothing changed costs nothing, which is the whole \
reason damage is tracked"
);
}
#[test]
fn a_decoration_outside_the_damage_contributes_nothing() {
let pixel = one_pixel([0xFF, 0xFF, 0xFF, 0xFF]);
let items = [BlitItem {
pixels: &pixel,
stride: 4,
width: 1,
height: 1,
fourcc: fourcc::ARGB8888,
x: 7,
y: 3,
}];
let mut framebuffer = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut shadow = vec![0u8; (FB_W * FB_H * 4) as usize];
let mut target = FbTarget {
pixels: &mut framebuffer,
stride: FB_W * 4,
width: FB_W,
height: FB_H,
fourcc: fourcc::XRGB8888,
};
compose_into_framebuffer(
&mut target,
&mut shadow,
&items,
DamageRect {
x: 0,
y: 0,
w: 2,
h: 1,
},
);
let at = ((3 * FB_W + 7) * 4) as usize;
assert_eq!(
&framebuffer[at..at + 4],
&[0, 0, 0, 0],
"outside the damage means outside this frame's work"
);
}
use crate::compute_canvas_writes;
#[test]
fn the_canvas_is_armed_full_screen_and_unscaled() {
let canvas = PlaneSlot {
plane_id: 7,
crtc_id: 42,
..slot(7)
};
let writes = compute_canvas_writes(&canvas, 1234, 1920, 1080);
assert_eq!(writes.len(), 10, "ten properties arm a plane, no more");
assert_eq!(value_of(&writes, 7, canvas.fb_id_prop), Some(1234));
assert_eq!(value_of(&writes, 7, canvas.crtc_id_prop), Some(42));
assert_eq!(value_of(&writes, 7, canvas.crtc_x_prop), Some(0));
assert_eq!(value_of(&writes, 7, canvas.crtc_y_prop), Some(0));
assert_eq!(value_of(&writes, 7, canvas.crtc_w_prop), Some(1920));
assert_eq!(value_of(&writes, 7, canvas.crtc_h_prop), Some(1080));
assert_eq!(value_of(&writes, 7, canvas.src_x_prop), Some(0));
assert_eq!(value_of(&writes, 7, canvas.src_y_prop), Some(0));
assert_eq!(
value_of(&writes, 7, canvas.src_w_prop),
Some(1920 << 16),
"16.16 fixed point -- pixels here would ask for a source region 65536 \
times too small"
);
assert_eq!(value_of(&writes, 7, canvas.src_h_prop), Some(1080 << 16));
}
#[test]
fn every_canvas_write_targets_the_canvas_plane() {
let canvas = PlaneSlot {
plane_id: 3,
..slot(3)
};
let writes = compute_canvas_writes(&canvas, 55, 640, 480);
assert!(
writes.iter().all(|w| w.object_id == 3),
"a write aimed at another object would program a plane the composite \
tier does not own"
);
}
#[test]
fn the_canvas_framebuffer_is_written_verbatim() {
let canvas = PlaneSlot {
plane_id: 1,
..slot(1)
};
assert_eq!(
value_of(
&compute_canvas_writes(&canvas, 0, 800, 600),
1,
canvas.fb_id_prop
),
Some(0)
);
assert_eq!(
value_of(
&compute_canvas_writes(&canvas, 99, 800, 600),
1,
canvas.fb_id_prop
),
Some(99)
);
}
use crate::{SURFACE_FOURCC, Surface, SurfaceConfig, SurfaceError};
#[test]
fn a_default_surface_holds_nothing() {
let surface = Surface::default();
assert!(surface.is_empty());
assert_eq!(surface.fb_id(), 0, "zero is what disarms a plane");
assert_eq!(surface.width(), 0);
assert_eq!(surface.height(), 0);
assert_eq!(surface.stride(), 0);
assert_eq!(surface.generation(), 0);
}
#[test]
fn the_format_is_always_argb8888() {
assert_eq!(Surface::default().format(), SURFACE_FOURCC);
assert_eq!(SURFACE_FOURCC, drmkit_fmt::fourcc::ARGB8888);
}
#[test]
fn painting_an_empty_surface_is_refused() {
let mut surface = Surface::default();
assert!(matches!(
surface.paint(drmkit_dumb::MapAccess::ReadWrite),
Err(SurfaceError::Empty)
));
assert_eq!(
surface.generation(),
0,
"a refused paint is not a paint -- counting it would report a change \
that never happened"
);
}
#[test]
fn forgetting_an_empty_surface_is_harmless() {
let mut surface = Surface::default();
surface.forget();
surface.forget();
assert!(surface.is_empty());
}
#[test]
fn a_zero_dimension_is_refused_by_name() {
let _guard = card_guard();
let Some(device) = open_card() else { return };
for (field, config) in [
(
"width",
SurfaceConfig {
width: 0,
height: 64,
},
),
(
"height",
SurfaceConfig {
width: 64,
height: 0,
},
),
] {
let error =
Surface::create(&device, config).expect_err("a zero dimension is not a surface");
assert!(
matches!(error, SurfaceError::Invalid { field: named } if named == field),
"asked about {field} and got {error}"
);
}
}
#[test]
#[ignore = "needs a DRM device; run under the vkms lane with --include-ignored"]
fn a_real_surface_reports_its_shape_vkms() {
let _guard = card_guard();
let Some(device) = open_card() else { return };
let surface = Surface::create(
&device,
SurfaceConfig {
width: 64,
height: 32,
},
)
.expect("a 64x32 ARGB8888 surface");
assert!(!surface.is_empty());
assert_eq!(surface.width(), 64);
assert_eq!(surface.height(), 32);
assert!(surface.stride() >= 64 * 4);
assert_ne!(
surface.fb_id(),
0,
"a decoration with no framebuffer cannot reach a plane"
);
}
#[test]
#[ignore = "needs a DRM device; run under the vkms lane with --include-ignored"]
fn painting_bumps_the_generation_vkms() {
let _guard = card_guard();
let Some(device) = open_card() else { return };
let mut surface = Surface::create(
&device,
SurfaceConfig {
width: 64,
height: 32,
},
)
.expect("a surface");
assert_eq!(surface.generation(), 0);
{
let _mapping = surface.paint(drmkit_dumb::MapAccess::Write).expect("map");
}
assert_eq!(surface.generation(), 1);
{
let _mapping = surface
.paint(drmkit_dumb::MapAccess::ReadWrite)
.expect("map");
}
assert_eq!(surface.generation(), 2);
{
let _mapping = surface.paint(drmkit_dumb::MapAccess::Read).expect("map");
}
assert_eq!(
surface.generation(),
2,
"reading is not painting -- counting it would recomposite a \
decoration that nobody touched"
);
}
#[test]
#[ignore = "needs a DRM device; run under the vkms lane with --include-ignored"]
fn forgetting_a_real_surface_empties_it_vkms() {
let _guard = card_guard();
let Some(device) = open_card() else { return };
let mut surface = Surface::create(
&device,
SurfaceConfig {
width: 64,
height: 32,
},
)
.expect("a surface");
assert!(!surface.is_empty());
surface.forget();
assert!(surface.is_empty());
assert_eq!(surface.fb_id(), 0);
assert!(matches!(
surface.paint(drmkit_dumb::MapAccess::ReadWrite),
Err(SurfaceError::Empty)
));
}
#[test]
fn exporting_an_empty_surface_is_refused() {
let _guard = card_guard();
let Some(device) = open_card() else { return };
assert!(matches!(
Surface::default().export(&device),
Err(SurfaceError::Empty)
));
}