use std::{cell::Cell, rc::Rc};
use cranpose_foundation::text::TextFieldState;
use cranpose_liquid::prelude::*;
use cranpose_ui::{Modifier, run_test_composition, widgets::popup::PopupHost};
use cranpose_ui_graphics::Color;
fn in_theme(content: impl FnMut() + 'static) {
let mut content = content;
run_test_composition(move || {
LiquidTheme(LiquidThemeSpec::default(), &mut content);
});
}
fn themed(content: impl FnMut() + 'static) {
let mut content = content;
let composition = run_test_composition(move || {
LiquidTheme(LiquidThemeSpec::default(), &mut content);
});
assert!(
composition.root().is_some(),
"the composition produced no root node"
);
}
fn composed_in_theme_and_popup_host(content: impl Fn() + 'static) {
let content = Rc::new(content);
let mut composition = run_test_composition(move || {
let content = Rc::clone(&content);
let mut host = move || {
let content = Rc::clone(&content);
PopupHost(move || content());
};
LiquidTheme(LiquidThemeSpec::default(), &mut host);
});
let root = composition
.root()
.expect("the composition produced no root node");
let handle = composition.runtime_handle();
let mut applier = composition.applier_mut();
applier.set_runtime_handle(handle);
let measured = cranpose_ui::measure_layout(
&mut applier,
root,
cranpose_ui_graphics::Size::new(400.0, 800.0),
);
applier.clear_runtime_handle();
measured.expect("the tree measures");
}
#[test]
fn tint_amount_is_scoped_and_restored_by_the_theme() {
in_theme(|| {
assert_eq!(liquid_glass_tint_amount(), GlassTintAmount::default());
for percent in [0.0, 25.0, 50.0, 100.0] {
let amount = GlassTintAmount::from_percent(percent).unwrap();
LiquidTheme(
LiquidThemeSpec {
glass_tint_amount: amount,
..Default::default()
},
|| {
assert_eq!(liquid_glass_tint_amount(), amount);
},
);
assert_eq!(liquid_glass_tint_amount(), GlassTintAmount::default());
}
});
}
#[test]
fn a_glass_surface_composes_its_content() {
let drawn = Rc::new(Cell::new(0usize));
let counter = Rc::clone(&drawn);
themed(move || {
GlassSurface(Modifier::empty(), Glass::default(), {
let counter = Rc::clone(&counter);
move || counter.set(counter.get() + 1)
});
});
assert_eq!(drawn.get(), 1, "the surface did not compose its content");
}
#[test]
fn a_glass_button_label_composes_inside_a_button_spec() {
themed(|| {
GlassButtonLabel("Add", GlassButtonSpec::prominent());
});
}
#[test]
fn a_glass_icon_button_composes_at_its_diameter() {
themed(|| {
GlassIconButton(
Modifier::empty(),
GlassButtonSpec::glass(),
44.0,
|| {},
cranpose_liquid::icons::SEARCH,
);
});
}
#[test]
fn a_glass_icon_button_group_composes_each_action() {
let actions = Rc::new(Cell::new(0usize));
let counter = Rc::clone(&actions);
themed(move || {
GlassIconButtonGroup(
Modifier::empty(),
GlassIconButtonGroupSpec::default(),
|scope| {
for _ in 0..3 {
counter.set(counter.get() + 1);
scope.action(cranpose_liquid::icons::SEARCH, "Search", || {});
}
},
);
});
assert_eq!(actions.get(), 3, "the group did not run its content scope");
}
#[test]
fn an_icon_composes_at_a_size_and_colour() {
themed(|| {
cranpose_liquid::icons::Icon(
cranpose_liquid::icons::SEARCH,
Some("Search".into()),
24.0,
Color::WHITE,
);
});
}
#[test]
fn a_card_composes_its_content() {
let inner = Rc::new(Cell::new(false));
let flag = Rc::clone(&inner);
themed(move || {
LiquidCard(Modifier::empty(), {
let flag = Rc::clone(&flag);
move || flag.set(true)
});
});
assert!(inner.get(), "the card did not compose its content");
}
#[test]
fn a_chip_composes_selected_and_unselected() {
for selected in [false, true] {
themed(move || {
LiquidChip(Modifier::empty(), selected, || {}, "Filter");
});
}
}
#[test]
fn a_list_section_composes_its_header_and_rows() {
let rows = Rc::new(Cell::new(0usize));
let counter = Rc::clone(&rows);
themed(move || {
LiquidListSection(Modifier::empty(), "Section", {
let counter = Rc::clone(&counter);
move || {
counter.set(counter.get() + 1);
LiquidListRow(
Modifier::empty(),
LiquidListRowSpec::default(),
|| {},
|| {},
);
}
});
});
assert_eq!(rows.get(), 1, "the section did not compose its rows");
}
#[test]
fn a_nav_bar_composes_both_of_its_slots() {
let slots = Rc::new(Cell::new(0usize));
let leading = Rc::clone(&slots);
let trailing = Rc::clone(&slots);
themed(move || {
let scroll = cranpose_core::remember(|| cranpose_ui::scroll::ScrollState::new(0.0))
.with(Clone::clone);
LiquidNavBar(
Modifier::empty(),
LiquidNavBarSpec::new("Title"),
scroll,
{
let leading = Rc::clone(&leading);
move || leading.set(leading.get() + 1)
},
{
let trailing = Rc::clone(&trailing);
move || trailing.set(trailing.get() + 1)
},
);
});
assert_eq!(slots.get(), 2, "a nav bar slot did not compose");
}
#[test]
fn a_segmented_control_composes_every_segment() {
let segments = Rc::new(Cell::new(0usize));
let counter = Rc::clone(&segments);
themed(move || {
LiquidSegmentedControl(
Modifier::empty(),
1,
|_| {},
|scope| {
for label in ["One", "Two", "Three"] {
counter.set(counter.get() + 1);
scope.segment(label);
}
},
);
});
assert_eq!(
segments.get(),
3,
"the control did not run its content scope"
);
}
#[test]
fn a_slider_composes_across_its_range() {
for value in [0.0, 0.5, 1.0] {
themed(move || {
LiquidSlider(Modifier::empty(), value, |_| {});
});
}
}
#[test]
fn a_toggle_composes_checked_and_unchecked() {
for checked in [false, true] {
themed(move || {
LiquidToggle(Modifier::empty(), checked, |_| {});
});
}
}
#[test]
fn a_tab_bar_with_an_accessory_composes_both() {
let accessory = Rc::new(Cell::new(0usize));
let counter = Rc::clone(&accessory);
themed(move || {
LiquidTabBarWithAccessory(
Modifier::empty(),
LiquidTabBarSpec::default(),
0,
|_| {},
|scope| {
scope.tab(cranpose_liquid::icons::SEARCH, "Search");
scope.tab(cranpose_liquid::icons::SEARCH, "Browse");
},
{
let counter = Rc::clone(&counter);
move || {
counter.set(counter.get() + 1);
LiquidTabBarSearchAccessory(|| {});
}
},
);
});
assert_eq!(accessory.get(), 1, "the accessory slot did not compose");
}
#[test]
fn a_menu_icon_button_composes_covered_and_uncovered() {
for covered in [false, true] {
themed(move || {
let gesture = rememberLiquidMenuGesture();
LiquidMenuIconButton(
Modifier::empty(),
GlassButtonSpec::glass(),
44.0,
covered,
gesture,
|| {},
cranpose_liquid::icons::SEARCH,
);
});
}
}
#[test]
fn an_absorbed_menu_icon_button_composes_transferred_and_not() {
for transferred in [false, true] {
themed(move || {
LiquidMenuAbsorbedIconButton(
Modifier::empty(),
GlassButtonSpec::glass(),
44.0,
transferred,
|| {},
cranpose_liquid::icons::SEARCH,
);
});
}
}
#[test]
fn a_menu_trigger_modifier_is_built_in_composition() {
in_theme(|| {
let gesture = rememberLiquidMenuGesture();
let _ = liquid_menu_trigger_input(Modifier::empty(), gesture, || {});
});
}
#[test]
fn a_menu_gesture_reports_no_press_point_until_it_is_pressed() {
in_theme(|| {
let gesture = rememberLiquidMenuGesture();
assert!(
gesture.press_point().is_none(),
"a gesture nobody touched reported a press point"
);
});
}
#[test]
fn press_scale_hands_back_a_modifier_and_two_observable_values() {
in_theme(|| {
let interaction = cranpose_ui::rememberMutableInteractionSource();
let (_modifier, pressed, scale) = liquid_press_scale(Modifier::empty(), interaction, 1.12);
assert!(!pressed.get(), "nothing was pressed yet");
assert_eq!(scale.get(), 1.0, "the resting scale is unit");
});
}
#[test]
fn dynamics_are_remembered_once_per_composition() {
let observed: Rc<std::cell::RefCell<Vec<*const LiquidDynamics>>> = Rc::default();
let sink = Rc::clone(&observed);
in_theme(move || {
sink.borrow_mut()
.push(Rc::as_ptr(&rememberLiquidDynamics()));
sink.borrow_mut()
.push(Rc::as_ptr(&rememberLiquidDynamics()));
});
let seen = observed.borrow();
assert_eq!(seen.len(), 2);
assert_ne!(
seen[0], seen[1],
"two call sites must remember two independent instances"
);
}
#[test]
fn the_theme_answers_colours_and_typography_inside_composition() {
in_theme(|| {
let colors = liquid_colors();
let typography = liquid_typography();
assert!(
!typography.body.span_style.font_size.is_unspecified(),
"the body style has no size"
);
assert_ne!(
colors.label, colors.surface,
"the scheme cannot tell content from surface"
);
});
}
#[test]
fn a_button_spec_resolves_its_icon_colour_against_the_scheme() {
in_theme(|| {
let colors = liquid_colors();
let plain = GlassButtonSpec::glass().icon_color(&colors);
let tinted = GlassButtonSpec::glass()
.with_content_color(Color::RED)
.icon_color(&colors);
assert_eq!(tinted, Color::RED, "an explicit content colour must win");
assert_ne!(
plain, tinted,
"the scheme's colour and an explicit one must differ"
);
});
}
#[test]
fn a_button_spec_carries_the_glass_it_is_given() {
let spec = GlassButtonSpec::glass().with_glass(Glass::default());
let plain = GlassButtonSpec::glass();
assert_ne!(
format!("{spec:?}"),
format!("{plain:?}"),
"with_glass did not record the material"
);
}
#[test]
fn a_glass_button_composes_its_content() {
let drawn = Rc::new(Cell::new(0usize));
let counter = Rc::clone(&drawn);
themed(move || {
let counter = Rc::clone(&counter);
GlassButton(
Modifier::empty(),
GlassButtonSpec::glass(),
|| {},
move || counter.set(counter.get() + 1),
);
});
assert_eq!(
drawn.get(),
1,
"the glass button did not compose its content"
);
}
#[test]
fn a_plain_card_composes_its_content() {
let drawn = Rc::new(Cell::new(false));
let flag = Rc::clone(&drawn);
themed(move || {
Card(Modifier::empty(), {
let flag = Rc::clone(&flag);
move || flag.set(true)
});
});
assert!(drawn.get(), "the card did not compose its content");
}
#[test]
fn a_surface_composes_its_content() {
let drawn = Rc::new(Cell::new(false));
let flag = Rc::clone(&drawn);
themed(move || {
Surface(Modifier::empty(), {
let flag = Rc::clone(&flag);
move || flag.set(true)
});
});
assert!(drawn.get(), "the surface did not compose its content");
}
#[test]
fn a_liquid_search_field_composes_inline_without_glass() {
themed(|| {
let state = TextFieldState::new("");
LiquidSearchField(
Modifier::empty(),
state,
LiquidSearchFieldSpec {
placeholder: "Search".to_string(),
on_glass: false,
..Default::default()
},
);
});
}
#[test]
fn a_search_field_composes_on_glass_through_search_bar() {
themed(|| {
let state = TextFieldState::new("");
SearchField(Modifier::empty(), state, "Search");
});
}
#[test]
fn a_tab_bar_without_an_accessory_composes_its_tabs() {
themed(|| {
LiquidTabBar(
Modifier::empty(),
LiquidTabBarSpec::default(),
0,
|_| {},
|scope| {
scope.tab(cranpose_liquid::icons::SEARCH, "Search");
scope.tab(cranpose_liquid::icons::SEARCH, "Browse");
},
);
});
}
#[test]
fn tab_bar_announces_the_committed_selection() {
let mut rule = cranpose_testing::ComposeTestRule::new();
rule.set_content(|| {
LiquidTheme(LiquidThemeSpec::default(), || {
LiquidTabBar(
Modifier::empty(),
LiquidTabBarSpec::default(),
1,
|_| {},
|tabs| {
tabs.tab(cranpose_liquid::icons::STAR, "Discover");
tabs.tab(cranpose_liquid::icons::BOOKMARK, "Saved");
},
);
});
})
.expect("compose tabs");
rule.placed_semantics(cranpose_ui::Size::new(400.0, 800.0))
.expect("place tabs");
let root = rule.root_id().expect("root");
let tree = cranpose_ui::build_semantics_tree_from_applier(&mut rule.applier_mut(), root)
.expect("semantics")
.expect("semantics tree");
let mut pending = vec![tree.root()];
let mut tabs = Vec::new();
while let Some(node) = pending.pop() {
if node.widget_role == Some(cranpose_ui::SemanticsWidgetRole::Tab) {
tabs.push((node.description.clone(), node.selected));
}
pending.extend(node.children.iter());
}
tabs.sort();
assert_eq!(
tabs,
vec![
(Some("Discover".to_string()), Some(false)),
(Some("Saved".to_string()), Some(true)),
]
);
}
#[test]
fn a_liquid_menu_composes_its_header_and_item_rows_while_expanded() {
composed_in_theme_and_popup_host(|| {
let gesture = rememberLiquidMenuGesture();
LiquidMenu(
true,
cranpose_ui_graphics::Rect::from_size(cranpose_ui_graphics::Size::new(44.0, 44.0)),
LiquidMenuSpec::default(),
Vec::new(),
gesture,
|| {},
|scope| {
scope.header("Show");
scope.item(LiquidMenuItem::new("List"), || {});
scope.item(LiquidMenuItem::new("Grid"), || {});
},
);
});
}
#[test]
fn a_liquid_dropdown_menu_composes_its_anchor_content() {
let anchor_drawn = Rc::new(Cell::new(0usize));
let counter = Rc::clone(&anchor_drawn);
composed_in_theme_and_popup_host(move || {
let counter = Rc::clone(&counter);
LiquidDropdownMenu(
Modifier::empty(),
true,
LiquidDropdownMenuSpec::default(),
|| {},
move || counter.set(counter.get() + 1),
|scope| {
scope.item(LiquidMenuItem::new("Delete"), || {});
},
);
});
assert_eq!(
anchor_drawn.get(),
1,
"the dropdown menu did not compose its anchor content"
);
}
#[test]
fn glass_modifier_evaluates_live_dynamics_after_reusing_a_layer() {
let app_context = cranpose_ui::AppContext::new();
let _scope = app_context.enter_scope();
use std::cell::RefCell;
let calls = Rc::new(Cell::new(0));
let boost = Rc::new(Cell::new(0.0));
let modifier = Rc::new(RefCell::new(Modifier::empty()));
in_theme({
let calls = Rc::clone(&calls);
let boost = Rc::clone(&boost);
let modifier = Rc::clone(&modifier);
move || {
*modifier.borrow_mut() = Modifier::empty().glass_effect_with(Glass::regular(), {
let calls = Rc::clone(&calls);
let boost = Rc::clone(&boost);
move || {
calls.set(calls.get() + 1);
GlassDynamics {
highlight_boost: boost.get(),
..Default::default()
}
}
});
}
});
let slices = cranpose_ui::collect_slices_from_modifier(&modifier.borrow());
let read_layer = || slices.graphics_layer().expect("glass graphics layer");
let first = read_layer();
let first_calls = calls.get();
assert_eq!(read_layer(), first);
assert_eq!(calls.get(), first_calls + 1);
boost.set(0.5);
assert_ne!(read_layer(), first);
assert_eq!(calls.get(), first_calls + 2);
boost.set(0.0);
assert_eq!(read_layer(), first);
}
#[test]
fn the_scheme_tells_the_selection_menu_which_ink_its_surface_needs() {
for (scheme, on_light) in [(SchemeMode::Light, true), (SchemeMode::Dark, false)] {
let seen = Rc::new(Cell::new(None));
let sink = Rc::clone(&seen);
let mut content = move || sink.set(Some(cranpose_ui::local_on_light_surface().current()));
run_test_composition(move || {
LiquidTheme(
LiquidThemeSpec {
scheme,
..Default::default()
},
&mut content,
);
});
assert_eq!(seen.get(), Some(on_light), "{scheme:?}");
}
}