tests.rs
raw
use guiduck_scene::geom::{Rect, Size};
use guiduck_scene::paint::color::palette::css;
use guiduck_scene::{DisplayItem, FragmentStore};
use taffy::prelude::{auto, length, percent};
use super::*;
use crate::text::TextContext;
fn tree() -> WidgetTree {
// Layout tests must not depend on machine fonts.
WidgetTree::with_text_context(TextContext::hermetic([sample_font()]))
}
fn sample_font() -> guiduck_scene::paint::Blob<u8> {
static FONT: &[u8] = include_bytes!("../../../guiduck-samples/fonts/DejaVuSans.ttf");
guiduck_scene::paint::Blob::new(std::sync::Arc::new(FONT))
}
fn row_style(gap: f32, padding: f32) -> taffy::Style {
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_direction: taffy::FlexDirection::Row,
gap: taffy::Size {
width: length(gap),
height: length(gap),
},
padding: taffy::Rect::length(padding),
..Default::default()
}
}
#[test]
fn flex_row_with_gap_and_padding() {
let mut tree = tree();
let root = tree.insert(Container::new(), row_style(10.0, 8.0), None);
let a = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: length(100.0_f32),
height: length(50.0_f32),
},
..Default::default()
},
Some(root),
);
let b = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: length(60.0_f32),
height: auto(),
},
flex_grow: 0.0,
align_self: Some(taffy::AlignItems::STRETCH),
..Default::default()
},
Some(root),
);
tree.compute_layout(Size::new(400.0, 300.0));
assert_eq!(tree.layout(root), Rect::new(0.0, 0.0, 400.0, 300.0));
assert_eq!(tree.layout(a), Rect::new(8.0, 8.0, 108.0, 58.0));
// After a and the 10px gap; stretches to the padded height.
assert_eq!(tree.layout(b), Rect::new(118.0, 8.0, 178.0, 292.0));
}
#[test]
fn text_measures_and_wraps() {
let mut tree = tree();
// The root is the viewport; what this needs from it is only that it not
// stretch the text, so the height measured is the text's own.
let root = tree.insert(
Container::new(),
taffy::Style {
align_items: Some(taffy::AlignItems::FLEX_START),
..Default::default()
},
None,
);
let text = tree.insert(
Text::new("one two three four five six seven eight nine ten")
.font_size(16.0)
.family(SAMPLE_FAMILY),
taffy::Style::default(),
Some(root),
);
tree.compute_layout(Size::new(500.0, 400.0));
let wide = tree.layout(text);
let mut narrow_tree = self::tree();
let root2 = narrow_tree.insert(
Container::new(),
taffy::Style {
align_items: Some(taffy::AlignItems::FLEX_START),
..Default::default()
},
None,
);
let text2 = narrow_tree.insert(
Text::new("one two three four five six seven eight nine ten")
.font_size(16.0)
.family(SAMPLE_FAMILY),
taffy::Style::default(),
Some(root2),
);
narrow_tree.compute_layout(Size::new(120.0, 400.0));
let narrow = narrow_tree.layout(text2);
assert!(wide.height() > 0.0);
assert!(
narrow.height() > wide.height() * 1.5,
"narrow viewport should wrap to more lines: wide {wide:?} narrow {narrow:?}"
);
assert!(narrow.width() <= 120.0 + f64::EPSILON);
}
const SAMPLE_FAMILY: &str = "DejaVu Sans";
#[test]
fn image_keeps_aspect_ratio() {
let mut tree = tree();
let image = guiduck_scene::paint::ImageBrush::from(guiduck_scene::paint::ImageData {
data: guiduck_scene::paint::Blob::new(std::sync::Arc::new(vec![0u8; 40 * 20 * 4])),
format: guiduck_scene::paint::ImageFormat::Rgba8,
alpha_type: guiduck_scene::paint::ImageAlphaType::Alpha,
width: 40,
height: 20,
});
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
},
None,
);
let img = tree.insert(
Image::new(crate::graphic::Graphic::Image(image)),
taffy::Style {
size: taffy::Size {
width: length(80.0_f32),
height: auto(),
},
// Without this, flexbox's default stretch overrides the measured
// height — correct CSS behavior, but not what this test checks.
align_self: Some(taffy::AlignItems::FLEX_START),
..Default::default()
},
Some(root),
);
tree.compute_layout(Size::new(200.0, 200.0));
let rect = tree.layout(img);
assert_eq!(rect.width(), 80.0);
assert_eq!(rect.height(), 40.0, "2:1 aspect preserved");
}
#[test]
fn paint_composes_child_fragments_at_layout_positions() {
let mut tree = tree();
let root = tree.insert(
Container::new().background(css::WHITE),
row_style(0.0, 20.0),
None,
);
tree.insert(
Container::new().background(css::RED),
taffy::Style {
size: taffy::Size {
width: length(50.0_f32),
height: length(50.0_f32),
},
..Default::default()
},
Some(root),
);
tree.compute_layout(Size::new(300.0, 100.0));
let mut store = FragmentStore::new();
let frame = tree.paint_fresh(&mut store).expect("tree has a root");
// The frame fragment wraps the base root (and any overlays) as child
// references; with no overlays it holds exactly one, at identity.
let frame_fragment = store.get(frame).unwrap();
let root_fragment = match frame_fragment.items.as_slice() {
[
DisplayItem::Child {
transform,
fragment,
},
] => {
assert_eq!(*transform, Affine::IDENTITY);
*fragment
}
items => panic!("frame should hold one child reference, got {items:?}"),
};
let fragment = store.get(root_fragment).unwrap();
assert!(fragment.is_balanced());
// Background fill plus one child reference placed at the padded origin.
let child = fragment
.items
.iter()
.find_map(|item| match item {
DisplayItem::Child {
transform,
fragment,
} => Some((*transform, *fragment)),
_ => None,
})
.expect("root has a child item");
assert_eq!(
child.0 * guiduck_scene::geom::Point::ORIGIN,
guiduck_scene::geom::Point::new(20.0, 20.0)
);
let child_fragment = store.get(child.1).unwrap();
assert!(
child_fragment
.items
.iter()
.any(|i| matches!(i, DisplayItem::Fill { .. }))
);
}
#[test]
fn accessibility_tree_has_roles_and_absolute_bounds() {
let mut tree = tree();
let root = tree.insert(Container::new(), row_style(0.0, 10.0), None);
let label = tree.insert(
Text::new("hello").family(SAMPLE_FAMILY),
taffy::Style::default(),
Some(root),
);
tree.compute_layout(Size::new(200.0, 100.0));
let update = tree.accessibility_tree();
let get = |id: accesskit::NodeId| update.nodes.iter().find(|(n, _)| *n == id).map(|(_, n)| n);
let window = get(accesskit::NodeId(u64::MAX)).expect("window root present");
assert_eq!(window.role(), accesskit::Role::Window);
let label_node = get(super::access_id(label)).expect("label node present");
assert_eq!(label_node.role(), accesskit::Role::Label);
let bounds = label_node.bounds().expect("label has bounds");
// Absolute coordinates include the parent's 10px padding.
assert_eq!(bounds.x0, 10.0);
assert_eq!(bounds.y0, 10.0);
assert_eq!(label_node.value(), Some("hello"));
let root_node = get(super::access_id(root)).expect("root node present");
assert_eq!(root_node.role(), accesskit::Role::GenericContainer);
assert_eq!(root_node.children(), &[super::access_id(label)]);
}
#[test]
fn tree_without_focusables_has_no_focus() {
let mut tree = tree();
let root = tree.insert(Container::new(), row_style(0.0, 0.0), None);
tree.insert(Container::new(), taffy::Style::default(), Some(root));
tree.render_frame(Size::new(100.0, 100.0));
assert_eq!(tree.focus(), None);
}
/// A focusable widget with a chosen cursor shape, for hover/cursor and
/// focus-restyle tests.
struct StateProbe {
shape: CursorShape,
}
impl StateProbe {
fn new(shape: CursorShape) -> Self {
Self { shape }
}
}
impl Widget for StateProbe {
fn cursor(&self, _local: Point) -> CursorShape {
self.shape
}
fn focusable(&self) -> bool {
true
}
}
fn probe_fixture() -> (WidgetTree, WidgetId) {
let mut tree = tree();
let root = tree.insert(Container::new(), row_style(0.0, 10.0), None);
let probe = tree.insert(
StateProbe::new(CursorShape::Pointer),
taffy::Style {
size: taffy::Size {
width: length(40.0_f32),
height: length(40.0_f32),
},
..Default::default()
},
Some(root),
);
(tree, probe)
}
#[test]
fn focus_moves_mark_both_ends_style_dirty() {
let mut tree = tree();
let root = tree.insert(Container::new(), row_style(0.0, 0.0), None);
let style = || taffy::Style {
size: taffy::Size {
width: length(40.0_f32),
height: length(40.0_f32),
},
..Default::default()
};
let a = tree.insert(StateProbe::new(CursorShape::Default), style(), Some(root));
let b = tree.insert(StateProbe::new(CursorShape::Default), style(), Some(root));
tree.render_frame(Size::new(100.0, 50.0));
assert_eq!(tree.focus(), Some(a));
// Moving focus must restyle both the old and new focus holders so a
// theme's `:focus` rule can apply and unapply. The style pass drains
// `style_dirty`, so it is empty here; set_focus repopulates it.
assert!(tree.style_dirty.is_empty());
tree.set_focus(Some(b));
assert!(tree.style_dirty.contains(&a), "old focus holder restyled");
assert!(tree.style_dirty.contains(&b), "new focus holder restyled");
}
#[test]
fn cursor_shape_comes_from_the_hovered_widget() {
let (mut tree, probe) = probe_fixture();
tree.render_frame(Size::new(200.0, 100.0));
assert_eq!(tree.cursor_shape(), CursorShape::Default);
tree.dispatch_pointer(
crate::event::PointerInput::Moved(Point::new(30.0, 30.0)),
&mut crate::clipboard::NoClipboard,
);
assert_eq!(tree.cursor_shape(), CursorShape::Pointer);
// Disabling retargets hover to the enabled ancestor, so the disabled
// control shows the default cursor, not its own.
tree.set_enabled(probe, false);
tree.dispatch_pointer(
crate::event::PointerInput::Moved(Point::new(31.0, 30.0)),
&mut crate::clipboard::NoClipboard,
);
assert_eq!(tree.cursor_shape(), CursorShape::Default);
tree.dispatch_pointer(
crate::event::PointerInput::Moved(Point::new(190.0, 90.0)),
&mut crate::clipboard::NoClipboard,
);
assert_eq!(tree.cursor_shape(), CursorShape::Default);
}
/// A widget outside the framework's own vocabulary, standing in for one an
/// app registers: the case `bind_dyn` exists for is precisely a widget whose
/// type the code installing the binding cannot name. Its two setters
/// classify their dirt differently, so a test can tell which one ran and
/// with what class.
#[derive(Default)]
struct BindProbe {
label: String,
highlight: bool,
dirt: Dirt,
}
impl BindProbe {
/// Layout-classed, as text content is.
fn set_label(&mut self, label: String) {
if self.label != label {
self.label = label;
self.dirt.mark_layout();
}
}
/// Paint-classed: appearance within the same geometry.
fn set_highlight(&mut self, highlight: bool) {
if self.highlight != highlight {
self.highlight = highlight;
self.dirt.mark_paint();
}
}
}
impl Widget for BindProbe {
fn take_dirt(&mut self) -> Dirt {
std::mem::take(&mut self.dirt)
}
}
/// The thunks a generated setter table would hold for [`BindProbe`]: the
/// widget's type and the value's are both recovered inside the closure.
fn label_thunk() -> SetterThunk {
Rc::new(|widget, value| {
if let (Some(widget), Ok(label)) = (
widget.downcast_mut::<BindProbe>(),
value.downcast::<String>(),
) {
widget.set_label(*label);
}
})
}
fn highlight_thunk() -> SetterThunk {
Rc::new(|widget, value| {
if let (Some(widget), Ok(highlight)) =
(widget.downcast_mut::<BindProbe>(), value.downcast::<bool>())
{
widget.set_highlight(*highlight);
}
})
}
/// Run the reactive and command halves of a frame — the two stages a binding
/// travels — and return the dirt they raised, leaving the tree clean for the
/// next observation. Stopping short of `render_frame` is what lets a test
/// see the dirt itself rather than its consequences.
fn settle(tree: &mut WidgetTree) -> Vec<(WidgetId, Dirt)> {
guiduck_signals::flush_effects();
tree.drain_commands();
std::mem::take(&mut tree.dirt)
}
fn bind_probe_fixture() -> (WidgetTree, WidgetId) {
let mut tree = tree();
let root = tree.insert(Container::new(), row_style(0.0, 0.0), None);
let probe = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root));
// Insertion's own structural dirt is not what these tests are about.
settle(&mut tree);
(tree, probe)
}
#[test]
fn bind_dyn_applies_value_and_raises_the_setters_dirt() {
let (mut tree, probe) = bind_probe_fixture();
let label = guiduck_signals::Signal::new(String::from("first"));
tree.bind_dyn(
probe,
move || Box::new(label.get()) as Box<dyn Any>,
label_thunk(),
);
assert_eq!(settle(&mut tree), [(probe, Dirt::LAYOUT)]);
assert_eq!(tree.widget::<BindProbe>(probe).unwrap().label, "first");
label.set(String::from("second"));
assert_eq!(settle(&mut tree), [(probe, Dirt::LAYOUT)]);
assert_eq!(tree.widget::<BindProbe>(probe).unwrap().label, "second");
// The setter compares and sets, so a mutation that changes nothing marks
// nothing — the erased borrow must not manufacture dirt of its own.
tree.widget_dyn_mut(probe)
.expect("probe exists")
.downcast_mut::<BindProbe>()
.expect("probe is a BindProbe")
.set_label(String::from("second"));
assert_eq!(settle(&mut tree), []);
}
#[test]
fn bind_dyn_carries_the_paint_class_too() {
let (mut tree, probe) = bind_probe_fixture();
let highlight = guiduck_signals::Signal::new(false);
tree.bind_dyn(
probe,
move || Box::new(highlight.get()) as Box<dyn Any>,
highlight_thunk(),
);
// The first run applies `false` over the default `false`: the setter
// marks nothing, so the binding raises nothing.
assert_eq!(settle(&mut tree), []);
highlight.set(true);
assert_eq!(settle(&mut tree), [(probe, Dirt::PAINT)]);
assert!(tree.widget::<BindProbe>(probe).unwrap().highlight);
}
#[test]
fn bind_dyn_matches_typed_bind_in_state_and_dirt() {
let mut tree = tree();
let root = tree.insert(Container::new(), row_style(0.0, 0.0), None);
let typed = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root));
let erased = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root));
settle(&mut tree);
let label = guiduck_signals::Signal::new(String::from("first"));
let highlight = guiduck_signals::Signal::new(false);
tree.bind::<BindProbe, _>(typed, move || label.get(), |w, v| w.set_label(v));
tree.bind_dyn(
erased,
move || Box::new(label.get()) as Box<dyn Any>,
label_thunk(),
);
tree.bind::<BindProbe, _>(typed, move || highlight.get(), |w, v| w.set_highlight(v));
tree.bind_dyn(
erased,
move || Box::new(highlight.get()) as Box<dyn Any>,
highlight_thunk(),
);
// Each write drives both widgets through their respective path; the two
// must agree on the value that lands and on the dirt it raises.
for (label_value, highlight_value, expected) in [
// A new label alone: layout-classed.
("first", false, Dirt::LAYOUT),
// A new label and a new highlight in one settle: their classes
// union, and layout subsumes paint.
("second", true, Dirt::LAYOUT),
// The label is unchanged (so its effect never re-runs); only the
// highlight moves, and its class is paint.
("second", false, Dirt::PAINT),
("third", false, Dirt::LAYOUT),
] {
label.set(String::from(label_value));
highlight.set(highlight_value);
let dirt = settle(&mut tree);
let dirt_for = |id| {
dirt.iter()
.filter(|(dirty, _)| *dirty == id)
.fold(Dirt::CLEAN, |acc, (_, d)| acc.union(*d))
};
assert_eq!(
dirt_for(typed),
dirt_for(erased),
"same mutation, same dirt ({label_value}, {highlight_value})"
);
assert_eq!(
dirt_for(erased),
expected,
"the class the setter marked reaches the tree ({label_value}, {highlight_value})"
);
let typed_probe = tree.widget::<BindProbe>(typed).unwrap();
let erased_probe = tree.widget::<BindProbe>(erased).unwrap();
assert_eq!(typed_probe.label, erased_probe.label);
assert_eq!(typed_probe.highlight, erased_probe.highlight);
assert_eq!(typed_probe.label, label_value);
assert_eq!(typed_probe.highlight, highlight_value);
}
}
#[test]
fn bind_dyn_is_inert_when_the_thunk_does_not_recognize_its_target() {
let (mut tree, probe) = bind_probe_fixture();
let root = tree.parent(probe).expect("probe has a parent");
let value = guiduck_signals::Signal::new(String::from("first"));
// A thunk aimed at a widget of another type, and one handed a value of
// another type: both apply nothing, exactly as a `mutate::<T>` whose
// downcast fails does, and neither raises dirt.
tree.bind_dyn(
root,
move || Box::new(value.get()) as Box<dyn Any>,
label_thunk(),
);
tree.bind_dyn(
probe,
move || Box::new(value.get().len()) as Box<dyn Any>,
label_thunk(),
);
assert_eq!(settle(&mut tree), []);
assert_eq!(tree.widget::<BindProbe>(probe).unwrap().label, "");
value.set(String::from("second"));
assert_eq!(settle(&mut tree), []);
assert_eq!(tree.widget::<BindProbe>(probe).unwrap().label, "");
}
#[test]
fn bind_dyn_on_a_removed_widget_is_a_no_op() {
let (mut tree, probe) = bind_probe_fixture();
let value = guiduck_signals::Signal::new(String::from("first"));
tree.bind_dyn(
probe,
move || Box::new(value.get()) as Box<dyn Any>,
label_thunk(),
);
settle(&mut tree);
tree.remove(probe);
settle(&mut tree);
value.set(String::from("second"));
// The command finds no node; as with `mutate`, a vanished widget makes
// the update evaporate rather than panic.
settle(&mut tree);
assert!(tree.widget::<BindProbe>(probe).is_none());
}