tests.rs
raw
use std::cell::RefCell;
use std::rc::Rc;
use guiduck_scene::geom::{Point, Size};
use taffy::prelude::percent;
use super::*;
use crate::clipboard::NoClipboard;
use crate::event::{Key, KeyInput, Modifiers, PointerButton, PointerInput};
use crate::widget::{Container, WidgetId, WidgetTree};
fn sample_font() -> guiduck_scene::paint::Blob<u8> {
static FONT: &[u8] = include_bytes!("../../../../guiduck-samples/fonts/DejaVuSans.ttf");
static BLOB: std::sync::OnceLock<guiduck_scene::paint::Blob<u8>> = std::sync::OnceLock::new();
BLOB.get_or_init(|| guiduck_scene::paint::Blob::new(std::sync::Arc::new(FONT)))
.clone()
}
type Toggles = Rc<RefCell<Vec<bool>>>;
fn fixture() -> (WidgetTree, WidgetId, Toggles) {
let mut tree =
WidgetTree::with_text_context(crate::text::TextContext::hermetic([sample_font()]));
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: taffy::Rect::length(10.0_f32),
align_items: Some(taffy::AlignItems::FLEX_START),
..Default::default()
},
None,
);
let checkbox = tree.insert(
Checkbox::new().label("Enabled").family("DejaVu Sans"),
taffy::Style::default(),
Some(root),
);
let toggles: Toggles = Rc::new(RefCell::new(Vec::new()));
let sink = toggles.clone();
tree.on_event(
checkbox,
crate::event::EventKind::Toggled,
move |_ctx, ev| {
sink.borrow_mut()
.push(ev.toggled().expect("toggle payload"));
},
);
tree.render_frame(Size::new(300.0, 120.0));
(tree, checkbox, toggles)
}
fn center(tree: &WidgetTree, id: WidgetId) -> Point {
let origin = tree.absolute_origin(id);
let size = tree.layout(id).size();
Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0)
}
fn click(tree: &mut WidgetTree, pos: Point) {
tree.dispatch_pointer(
PointerInput::Down {
pos,
button: PointerButton::Left,
time: std::time::Instant::now(),
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos,
button: PointerButton::Left,
},
&mut NoClipboard,
);
}
#[test]
fn click_toggles_and_emits_the_bool_payload() {
let (mut tree, checkbox, toggles) = fixture();
let pos = center(&tree, checkbox);
click(&mut tree, pos);
assert!(tree.widget::<Checkbox>(checkbox).unwrap().is_checked());
assert_eq!(&*toggles.borrow(), &[true]);
click(&mut tree, pos);
assert!(!tree.widget::<Checkbox>(checkbox).unwrap().is_checked());
assert_eq!(&*toggles.borrow(), &[true, false]);
}
#[test]
fn space_toggles_a_focused_checkbox() {
let (mut tree, checkbox, toggles) = fixture();
assert_eq!(tree.focus(), Some(checkbox));
tree.dispatch_key(
&KeyInput {
key: Key::Character(" ".to_owned()),
modifiers: Modifiers::default(),
pressed: true,
},
&mut NoClipboard,
);
assert_eq!(&*toggles.borrow(), &[true]);
}
#[test]
fn programmatic_set_is_silent() {
let (mut tree, checkbox, toggles) = fixture();
// The controlled-input contract: a binding writing `checked` must not
// re-raise the toggle event, or the binding would loop.
tree.widget_mut::<Checkbox>(checkbox)
.unwrap()
.set_checked(true);
tree.render_frame(Size::new(300.0, 120.0));
assert!(tree.widget::<Checkbox>(checkbox).unwrap().is_checked());
assert!(toggles.borrow().is_empty());
}
#[test]
fn a_checkbox_can_still_be_themed_into_a_dot() {
// The look was never the hard part, and this is what `radio` does *not*
// exist for: `:check-mark` is a `Graphic`, so any theme can round the box
// and draw a dot. What a theme cannot do is tell assistive technology
// which control this is, or make choosing one-way — which is why `radio`
// is a widget and not this recipe.
let theme = crate::style::Theme::parse(
r##"(theme App
(rule (checkbox .dot)
:box-corner-radius 9
:check-mark (path (move 0.3 0.5) (cubic 0.3 0.39 0.39 0.3 0.5 0.3)
(cubic 0.61 0.3 0.7 0.39 0.7 0.5)
(cubic 0.7 0.61 0.61 0.7 0.5 0.7)
(cubic 0.39 0.7 0.3 0.61 0.3 0.5) (close))
:check-width 4))"##,
)
.expect("valid");
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([sample_font()]));
let root = tree.insert(Container::new(), taffy::Style::default(), None);
tree.set_theme(Some(theme));
let id = tree.insert(
Checkbox::new().label("a"),
taffy::Style::default(),
Some(root),
);
tree.set_classes(id, ["dot".to_owned()]);
tree.render_frame(Size::new(300.0, 120.0));
// The theme really did round it…
assert_eq!(
tree.widget::<Checkbox>(id)
.expect("a checkbox")
.box_corner_radius_for_test(),
9.0,
);
// …and it still announces itself as a checkbox, whatever it looks like.
assert_eq!(
tree.widget::<Checkbox>(id).expect("a checkbox").role(),
accesskit::Role::CheckBox,
);
}
/// The reason a radio is its own widget rather than a themed checkbox: a
/// screen reader is told which control it is. A theme rule can round the box
/// and draw a dot, and the announcement would still say "checkbox".
#[test]
fn a_radio_announces_itself_as_one() {
assert_eq!(Radio::new().role(), accesskit::Role::RadioButton);
assert_eq!(Checkbox::new().role(), accesskit::Role::CheckBox);
assert_eq!(Radio::new().type_name(), "radio", "and themes as one");
}
/// The other reason: choosing is one-way. Clicking the chosen one is not a
/// change, so nothing is reported and nothing flips off.
#[test]
fn a_radio_does_not_unchoose_itself() {
let mut radio = Radio::new();
let mut text = TextContext::hermetic([]);
let click = |radio: &mut Radio, text: &mut TextContext| {
radio.on_pointer(
EventKind::Click,
&PointerEvent {
window: Point::ZERO,
local: Point::ZERO,
button: Some(crate::event::PointerButton::Left),
click_count: 1,
},
text,
&mut crate::clipboard::NoClipboard,
);
};
click(&mut radio, &mut text);
assert!(radio.is_checked());
assert_eq!(
radio.take_emitted(),
vec![EventData::Selected],
"choosing reports that it was chosen, not that it toggled"
);
click(&mut radio, &mut text);
assert!(radio.is_checked(), "still chosen");
assert!(
radio.take_emitted().is_empty(),
"choosing the chosen one is not a change"
);
}
/// A radio is a separate theme target: rules for one do not reach the other.
///
/// This is what the `type_name` override buys, and it is easy to lose by
/// delegating that method along with the rest — so it is pinned here. The
/// shipped default theme relies on it (a round box and a dot for `radio`, a
/// square and a tick for `checkbox`), and so does any app theme.
#[test]
fn a_radio_and_a_checkbox_are_styled_separately() {
let theme = crate::style::Theme::parse(
r##"(theme App
(rule checkbox :box-corner-radius 2)
(rule radio :box-corner-radius 9))"##,
)
.expect("valid");
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([sample_font()]));
let root = tree.insert(Container::new(), taffy::Style::default(), None);
tree.set_theme(Some(theme));
let checkbox = tree.insert(Checkbox::new(), taffy::Style::default(), Some(root));
let radio = tree.insert(Radio::new(), taffy::Style::default(), Some(root));
tree.render_frame(Size::new(300.0, 120.0));
assert_eq!(
tree.widget::<Checkbox>(checkbox)
.expect("a checkbox")
.box_corner_radius_for_test(),
2.0,
);
assert_eq!(
tree.widget::<Radio>(radio)
.expect("a radio")
.box_corner_radius_for_test(),
9.0,
"the radio took its own rule, not the checkbox's"
);
}
/// The four adapters differ in exactly three things, and this is the list.
///
/// Written as one table because they are one macro: if a fifth is added, the
/// question it has to answer is which row it occupies, and nothing else.
#[test]
fn the_adapters_differ_only_in_name_role_and_direction() {
assert_eq!(Radio::new().type_name(), "radio");
assert_eq!(Radio::new().role(), accesskit::Role::RadioButton);
assert_eq!(Switch::new().type_name(), "switch");
assert_eq!(Switch::new().role(), accesskit::Role::Switch);
assert_eq!(Tab::new().type_name(), "tab");
assert_eq!(Tab::new().role(), accesskit::Role::Tab);
assert_eq!(ListItem::new().type_name(), "list-item");
assert_eq!(ListItem::new().role(), accesskit::Role::ListItem);
// One-way: choosing the chosen one reports nothing. A switch is the
// exception, because a switch flips back.
let mut text = TextContext::hermetic([]);
let mut click = |widget: &mut dyn Widget| {
widget.on_pointer(
EventKind::Click,
&PointerEvent {
window: Point::ZERO,
local: Point::ZERO,
button: Some(PointerButton::Left),
click_count: 1,
},
&mut text,
&mut NoClipboard,
);
widget.take_emitted()
};
for widget in [
&mut Radio::new() as &mut dyn Widget,
&mut Tab::new(),
&mut ListItem::new(),
] {
assert_eq!(click(widget), vec![EventData::Selected]);
assert!(click(widget).is_empty(), "one-way: no second report");
}
let switch = &mut Switch::new() as &mut dyn Widget;
assert_eq!(click(switch), vec![EventData::Toggled(true)]);
assert_eq!(click(switch), vec![EventData::Toggled(false)], "and back");
}