diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index e880a9f5..1865c47f 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -716,3 +716,173 @@ where Self::new(value) } } + +#[cfg(test)] +mod tests { + use super::*; + use iced_widget::text::Text; + + #[derive(Clone, Debug)] + #[allow(dead_code)] + enum TestMessage { + Action1, + Action2, + } + + type TestMenuBar<'a> = MenuBar<'a, TestMessage, iced_widget::Theme, iced_widget::Renderer>; + + #[test] + fn menu_bar_new_creates_instance() { + let items = vec![Item::new(Text::new("File")), Item::new(Text::new("Edit"))]; + + let menu_bar = TestMenuBar::new(items); + assert_eq!(menu_bar.roots.len(), 2); + assert_eq!(menu_bar.spacing, Pixels::ZERO); + assert_eq!(menu_bar.width, Length::Shrink); + assert_eq!(menu_bar.height, Length::Shrink); + } + + #[test] + fn menu_bar_width_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).width(Length::Fill); + assert_eq!(menu_bar.width, Length::Fill); + } + + #[test] + fn menu_bar_height_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).height(Length::Fixed(50.0)); + assert_eq!(menu_bar.height, Length::Fixed(50.0)); + } + + #[test] + fn menu_bar_spacing_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).spacing(Pixels(10.0)); + assert_eq!(menu_bar.spacing, Pixels(10.0)); + } + + #[test] + fn menu_bar_padding_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).padding(Padding::new(5.0)); + assert_eq!(menu_bar.padding, Padding::new(5.0)); + } + + #[test] + fn menu_bar_safe_bounds_margin_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).safe_bounds_margin(100.0); + #[allow(clippy::float_cmp)] + { + assert_eq!(menu_bar.global_parameters.safe_bounds_margin, 100.0); + } + } + + #[test] + fn menu_bar_close_on_item_click_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).close_on_item_click(true); + assert_eq!(menu_bar.close_on_item_click, Some(true)); + } + + #[test] + fn menu_bar_close_on_background_click_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).close_on_background_click(true); + assert_eq!(menu_bar.close_on_background_click, Some(true)); + } + + #[test] + fn menu_bar_close_on_item_click_global_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).close_on_item_click_global(true); + assert!(menu_bar.global_parameters.close_on_item_click); + } + + #[test] + fn menu_bar_close_on_background_click_global_sets_value() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items).close_on_background_click_global(true); + assert!(menu_bar.global_parameters.close_on_background_click); + } + + #[test] + fn menu_bar_with_multiple_items() { + let items = vec![ + Item::new(Text::new("File")), + Item::new(Text::new("Edit")), + Item::new(Text::new("View")), + Item::new(Text::new("Help")), + ]; + + let menu_bar = TestMenuBar::new(items); + assert_eq!(menu_bar.roots.len(), 4); + } + + #[test] + fn menu_bar_chained_configuration() { + let items = vec![Item::new(Text::new("File"))]; + + let menu_bar = TestMenuBar::new(items) + .width(Length::Fill) + .height(Length::Fixed(40.0)) + .spacing(Pixels(5.0)) + .padding(Padding::new(10.0)) + .safe_bounds_margin(75.0) + .close_on_item_click(true) + .close_on_background_click(true); + + assert_eq!(menu_bar.width, Length::Fill); + assert_eq!(menu_bar.height, Length::Fixed(40.0)); + assert_eq!(menu_bar.spacing, Pixels(5.0)); + assert_eq!(menu_bar.padding, Padding::new(10.0)); + #[allow(clippy::float_cmp)] + { + assert_eq!(menu_bar.global_parameters.safe_bounds_margin, 75.0); + } + assert_eq!(menu_bar.close_on_item_click, Some(true)); + assert_eq!(menu_bar.close_on_background_click, Some(true)); + } + + #[test] + fn menu_bar_tag_returns_state_tag() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items); + + let tag = Widget::::tag(&menu_bar); + assert_eq!(tag, tree::Tag::of::()); + } + + #[test] + fn menu_bar_children_returns_item_trees() { + let items = vec![Item::new(Text::new("File")), Item::new(Text::new("Edit"))]; + + let menu_bar = TestMenuBar::new(items); + let children = + Widget::::children(&menu_bar); + assert_eq!(children.len(), 2); + } + + #[test] + fn menu_bar_size_returns_configured_size() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items) + .width(Length::Fill) + .height(Length::Fixed(40.0)); + + let size = + Widget::::size(&menu_bar); + assert_eq!(size.width, Length::Fill); + assert_eq!(size.height, Length::Fixed(40.0)); + } + + #[test] + fn menu_bar_converts_to_element() { + let items = vec![Item::new(Text::new("File"))]; + let menu_bar = TestMenuBar::new(items); + let _element: Element = + menu_bar.into(); + } +} diff --git a/src/widget/number_input.rs b/src/widget/number_input.rs index 70400d86..cda8d0df 100644 --- a/src/widget/number_input.rs +++ b/src/widget/number_input.rs @@ -593,17 +593,70 @@ where operation: &mut dyn Operation<()>, ) { operation.container(None, layout.bounds()); - operation.traverse(&mut |operation| { - self.content.operate( - &mut tree.children[0], - layout - .children() - .next() - .expect("NumberInput inner child Textbox was not created."), - renderer, - operation, - ); - }); + + let mut children = layout.children(); + + // Operate on the text input (first child) + if let Some(content_layout) = children.next() { + self.content + .operate(&mut tree.children[0], content_layout, renderer, operation); + } + + // Operate on the modifier buttons (second child) to expose them for testing + // The buttons are rendered as text elements, so we need to recreate their structure + if let Some(modifier_layout) = children.next() + && !self.ignore_buttons + { + let txt_size = self.size.unwrap_or_else(|| renderer.default_size()); + let icon_size = txt_size * 2.5 / 4.0; + + let btn_mod = |c| { + Container::::new( + Text::new(format!(" {c} ")).size(icon_size), + ) + .center_y(Length::Shrink) + .center_x(Length::Shrink) + }; + + let default_padding = DEFAULT_PADDING; + + // Recreate the modifier element structure to operate on it + let mut element = if self.padding.top < default_padding.top + || self.padding.bottom < default_padding.bottom + || self.padding.right < default_padding.right + { + Element::new( + Row::::new() + .spacing(1) + .width(Length::Shrink) + .push(btn_mod('+')) + .push(btn_mod('-')), + ) + } else { + Element::new( + Column::::new() + .spacing(1) + .width(Length::Shrink) + .push(btn_mod('▲')) + .push(btn_mod('▼')), + ) + }; + + // Get or create the tree for the modifier element + let modifier_tree = if let Some(child_tree) = tree.children.get_mut(1) { + child_tree.diff(element.as_widget_mut()); + child_tree + } else { + let child_tree = Tree::new(element.as_widget()); + tree.children.insert(1, child_tree); + &mut tree.children[1] + }; + + // Operate on the modifier element to expose button text + element + .as_widget_mut() + .operate(modifier_tree, modifier_layout, renderer, operation); + } } #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] @@ -1171,3 +1224,232 @@ where fn sorted_range(a: T, b: T) -> std::ops::Range { if a >= b { b..a } else { a..b } } + +#[cfg(test)] +mod tests { + use super::*; + use iced_widget::Renderer; + + #[derive(Clone, Debug)] + #[allow(dead_code)] + enum TestMessage { + Changed(u32), + Submit, + } + + type TestNumberInput<'a> = NumberInput<'a, u32, TestMessage, iced_widget::Theme, Renderer>; + + #[test] + fn number_input_new_creates_instance() { + let value = 10u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed); + + assert_eq!(input.value, 10); + assert_eq!(input.step, 1); + assert!(matches!(input.min, Bound::Included(0))); + assert!(matches!(input.max, Bound::Included(100))); + assert!(!input.ignore_scroll_events); + assert!(!input.ignore_buttons); + } + + #[test] + fn number_input_with_step() { + let value = 10u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed).step(5); + + assert_eq!(input.step, 5); + } + + #[test] + fn number_input_ignore_buttons() { + let value = 10u32; + let input = + TestNumberInput::new(&value, 0..=100, TestMessage::Changed).ignore_buttons(true); + + assert!(input.ignore_buttons); + } + + #[test] + fn number_input_ignore_scroll() { + let value = 10u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed).ignore_scroll(true); + + assert!(input.ignore_scroll_events); + } + + #[test] + fn number_input_bounds() { + let value = 50u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed).bounds(10..=90); + + assert!(matches!(input.min, Bound::Included(10))); + assert!(matches!(input.max, Bound::Included(90))); + } + + #[test] + fn number_input_can_increase() { + let value = 50u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed).step(10); + + assert!(input.can_increase()); + } + + #[test] + fn number_input_cannot_increase_at_max() { + let value = 100u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed); + + assert!(!input.can_increase()); + } + + #[test] + fn number_input_can_decrease() { + let value = 50u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed).step(10); + + assert!(input.can_decrease()); + } + + #[test] + fn number_input_cannot_decrease_at_min() { + let value = 0u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed); + + assert!(!input.can_decrease()); + } + + #[test] + fn number_input_valid_value() { + let value = 50u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed); + + assert!(input.valid(&50)); + assert!(input.valid(&0)); + assert!(input.valid(&100)); + assert!(!input.valid(&150)); + } + + #[test] + fn number_input_min_max_values() { + let value = 50u32; + let input = TestNumberInput::new(&value, 10..=90, TestMessage::Changed); + + assert_eq!(input.min(), 10); + assert_eq!(input.max(), 90); + } + + #[test] + fn number_input_min_max_with_excluded_bounds() { + let value = 50u32; + let input = TestNumberInput::new(&value, 10..90, TestMessage::Changed).step(1); + + // Range 10..90 means start is Included(10), end is Excluded(90) + assert_eq!(input.min(), 10); // Included bound + assert_eq!(input.max(), 89); // Excluded bound - step + } + + #[test] + fn number_input_disabled_when_bounds_too_tight() { + let value = 50u32; + // When min == max (50..=50), the widget is disabled because there's no room to change + let input = TestNumberInput::new(&value, 50..=50, TestMessage::Changed); + assert!(input.disabled()); + + // When min < max, the widget is not disabled + let input = TestNumberInput::new(&value, 49..=50, TestMessage::Changed); + assert!(!input.disabled()); + } + + #[test] + fn number_input_tag_returns_modifier_state_tag() { + let value = 10u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed); + + let tag = Widget::::tag(&input); + assert_eq!(tag, Tag::of::()); + } + + #[test] + fn number_input_has_one_child() { + let value = 10u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed); + + let children = Widget::::children(&input); + assert_eq!(children.len(), 1); // Only the content (TypedInput) is a child initially + } + + #[test] + fn number_input_different_values() { + let test_values = [(0, 0..=100), (50, 0..=100), (100, 0..=100), (25, 10..=50)]; + + for (value, range) in test_values { + let input = TestNumberInput::new(&value, range, TestMessage::Changed); + assert_eq!(input.value, value); + } + } + + #[test] + fn modifier_state_defaults() { + let state = ModifierState::default(); + + assert!(!state.decrease_pressed); + assert!(!state.increase_pressed); + } + + #[test] + fn number_input_with_on_submit() { + let value = 10u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed) + .on_submit(TestMessage::Submit); + + assert!(input.on_submit.is_some()); + } + + #[test] + fn number_input_padding() { + let value = 10u32; + let custom_padding = Padding::new(10.0); + let input = + TestNumberInput::new(&value, 0..=100, TestMessage::Changed).padding(custom_padding); + + assert_eq!(input.padding, custom_padding); + } + + #[test] + fn number_input_size() { + let value = 10u32; + let input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed).set_size(20.0); + + assert_eq!(input.size, Some(iced_core::Pixels(20.0))); + } + + #[test] + fn number_input_width() { + let value = 10u32; + let _input = TestNumberInput::new(&value, 0..=100, TestMessage::Changed).width(200); + + // We can't easily verify the width was set since it's stored in the content widget + // This test just ensures the method doesn't panic + } + + #[test] + fn sorted_range_ascending() { + let range = sorted_range(1, 10); + assert_eq!(range.start, 1); + assert_eq!(range.end, 10); + } + + #[test] + fn sorted_range_descending() { + let range = sorted_range(10, 1); + assert_eq!(range.start, 1); + assert_eq!(range.end, 10); + } + + #[test] + fn sorted_range_equal() { + let range = sorted_range(5, 5); + assert_eq!(range.start, 5); + assert_eq!(range.end, 5); + } +} diff --git a/tests/menu_bar_integration_tests.rs b/tests/menu_bar_integration_tests.rs new file mode 100644 index 00000000..fe670e20 --- /dev/null +++ b/tests/menu_bar_integration_tests.rs @@ -0,0 +1,408 @@ +//! Integration tests for the MenuBar widget +//! +//! These tests verify the MenuBar widget's behavior and public API +//! from an external perspective, testing the widget as a user of the +//! library would interact with it. + +// Simulator API https://raw.githubusercontent.com/iced-rs/iced/master/test/src/simulator.rs + +use iced::{Element, Settings}; +use iced_aw::menu::Menu; +use iced_aw::{menu_bar, menu_items}; +use iced_test::{Error, Simulator}; +use iced_widget::{button, text::Text}; + +#[derive(Clone, Debug, PartialEq)] +enum Message { + FileNew, + FileOpen, + FileSave, + EditCut, + EditCopy, +} + +type ViewFn = Box Element<'static, Message>>; + +#[derive(Clone)] +struct App { + view_fn: std::rc::Rc, +} + +impl App { + fn new(view_fn: F) -> (Self, iced::Task) + where + F: Fn() -> Element<'static, Message> + 'static, + { + ( + App { + view_fn: std::rc::Rc::new(Box::new(view_fn)), + }, + iced::Task::none(), + ) + } + + fn update(&mut self, _message: Message) { + // No state changes needed for these tests + } + + fn view(&self) -> Element<'_, Message> { + (self.view_fn)() + } +} + +fn simulator(app: &App) -> Simulator<'_, Message> { + Simulator::with_settings( + Settings { + ..Settings::default() + }, + app.view(), + ) +} + +// ============================================================================ +// Basic MenuBar Tests +// ============================================================================ + +#[test] +fn can_find_menu_bar_items() -> Result<(), Error> { + let (mut app, _) = App::new(|| { + menu_bar!( + (Text::new("File")), + (Text::new("Edit")), + (Text::new("View")) + ) + .into() + }); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + let mut ui = simulator(&app); + assert!(ui.find("File").is_ok(), "Should find File menu item"); + assert!(ui.find("Edit").is_ok(), "Should find Edit menu item"); + assert!(ui.find("View").is_ok(), "Should find View menu item"); + + Ok(()) +} + +#[test] +fn menu_bar_with_single_item() -> Result<(), Error> { + let (mut app, _) = App::new(|| menu_bar!((Text::new("File"))).into()); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + let mut ui = simulator(&app); + assert!(ui.find("File").is_ok(), "Should find File menu item"); + + Ok(()) +} + +#[test] +fn menu_bar_with_multiple_items() -> Result<(), Error> { + let (mut app, _) = App::new(|| { + menu_bar!( + (Text::new("File")), + (Text::new("Edit")), + (Text::new("View")), + (Text::new("Help")) + ) + .into() + }); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + let mut ui = simulator(&app); + assert!(ui.find("File").is_ok(), "Should find File menu item"); + assert!(ui.find("Edit").is_ok(), "Should find Edit menu item"); + assert!(ui.find("View").is_ok(), "Should find View menu item"); + assert!(ui.find("Help").is_ok(), "Should find Help menu item"); + + Ok(()) +} + +// ============================================================================ +// Nested Menu Tests +// ============================================================================ + +#[test] +fn menu_bar_with_submenu() -> Result<(), Error> { + let (mut app, _) = App::new(|| { + let file_menu = Menu::new(menu_items!( + (button(Text::new("New")).on_press(Message::FileNew)), + (button(Text::new("Open")).on_press(Message::FileOpen)), + (button(Text::new("Save")).on_press(Message::FileSave)) + )); + + menu_bar!((Text::new("File"), file_menu)).into() + }); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + let mut ui = simulator(&app); + assert!(ui.find("File").is_ok(), "Should find File menu item"); + + Ok(()) +} + +#[test] +fn clicking_menu_bar_item_shows_submenu() -> Result<(), Error> { + let (app, _) = App::new(|| { + let file_menu = Menu::new(menu_items!( + (button(Text::new("New")).on_press(Message::FileNew)), + (button(Text::new("Open")).on_press(Message::FileOpen)), + (button(Text::new("Save")).on_press(Message::FileSave)) + )); + + menu_bar!((Text::new("File"), file_menu)).into() + }); + + let mut ui = simulator(&app); + + // Click on File to open the menu + ui.click("File")?; + + // After clicking, the submenu items should be visible + assert!( + ui.find("New").is_ok(), + "Should find New menu item after clicking File" + ); + assert!( + ui.find("Open").is_ok(), + "Should find Open menu item after clicking File" + ); + assert!( + ui.find("Save").is_ok(), + "Should find Save menu item after clicking File" + ); + + Ok(()) +} + +// ============================================================================ +// Multiple Menus Tests +// ============================================================================ + +#[test] +fn menu_bar_with_multiple_menus() -> Result<(), Error> { + let (mut app, _) = App::new(|| { + let file_menu = Menu::new(menu_items!( + (button(Text::new("New")).on_press(Message::FileNew)), + (button(Text::new("Open")).on_press(Message::FileOpen)) + )); + + let edit_menu = Menu::new(menu_items!( + (button(Text::new("Cut")).on_press(Message::EditCut)), + (button(Text::new("Copy")).on_press(Message::EditCopy)) + )); + + menu_bar!( + (Text::new("File"), file_menu), + (Text::new("Edit"), edit_menu) + ) + .into() + }); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + let mut ui = simulator(&app); + assert!(ui.find("File").is_ok(), "Should find File menu"); + assert!(ui.find("Edit").is_ok(), "Should find Edit menu"); + + Ok(()) +} + +// ============================================================================ +// Keyboard Navigation Tests +// ============================================================================ + +#[test] +fn keyboard_navigation_in_menu() -> Result<(), Error> { + let (app, _) = App::new(|| { + let file_menu = Menu::new(menu_items!( + (button(Text::new("New")).on_press(Message::FileNew)), + (button(Text::new("Open")).on_press(Message::FileOpen)), + (button(Text::new("Save")).on_press(Message::FileSave)) + )); + + menu_bar!((Text::new("File"), file_menu)).into() + }); + + let mut ui = simulator(&app); + + // Click File to open menu + ui.click("File")?; + + // Try keyboard navigation with arrow keys + ui.tap_key(iced::keyboard::Key::Named( + iced::keyboard::key::Named::ArrowDown, + )); + + // The menu should still be visible + assert!( + ui.find("New").is_ok(), + "Menu should still be visible after arrow key" + ); + + Ok(()) +} + +// ============================================================================ +// Click Behavior Tests +// ============================================================================ + +#[test] +fn clicking_outside_closes_menu() -> Result<(), Error> { + let (app, _) = App::new(|| { + let file_menu = Menu::new(menu_items!( + (button(Text::new("New")).on_press(Message::FileNew)) + )); + + menu_bar!((Text::new("File"), file_menu)).into() + }); + + let mut ui = simulator(&app); + + // Click File to open menu + ui.click("File")?; + assert!(ui.find("New").is_ok(), "Menu should be open"); + + // Note: Clicking outside would require the simulator to support clicking at specific coordinates + // This test is a placeholder for that functionality + + Ok(()) +} + +// ============================================================================ +// Menu Item Interaction Tests +// ============================================================================ + +#[test] +fn menu_with_buttons_and_text() -> Result<(), Error> { + let (app, _) = App::new(|| { + let file_menu = Menu::new(menu_items!( + (button(Text::new("New")).on_press(Message::FileNew)), + (Text::new("Separator")), + (button(Text::new("Open")).on_press(Message::FileOpen)) + )); + + menu_bar!((Text::new("File"), file_menu)).into() + }); + + let mut ui = simulator(&app); + + // Click File to open menu + ui.click("File")?; + + assert!(ui.find("New").is_ok(), "Should find New button"); + assert!(ui.find("Separator").is_ok(), "Should find Separator text"); + assert!(ui.find("Open").is_ok(), "Should find Open button"); + + Ok(()) +} + +// ============================================================================ +// Empty and Edge Case Tests +// ============================================================================ + +#[test] +fn menu_bar_with_empty_text() -> Result<(), Error> { + let (mut app, _) = App::new(|| menu_bar!((Text::new(""))).into()); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + // Empty text should still create a menu bar item, just with no visible text + // The test verifies the menu bar doesn't crash with empty text + Ok(()) +} + +#[test] +fn menu_bar_with_unicode_text() -> Result<(), Error> { + let (mut app, _) = App::new(|| { + menu_bar!( + (Text::new("文件")), + (Text::new("编辑")), + (Text::new("🎨 View")) + ) + .into() + }); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + let mut ui = simulator(&app); + assert!(ui.find("文件").is_ok(), "Should find Chinese File menu"); + assert!(ui.find("编辑").is_ok(), "Should find Chinese Edit menu"); + assert!( + ui.find("🎨 View").is_ok(), + "Should find View menu with emoji" + ); + + Ok(()) +} + +// ============================================================================ +// Configuration Tests +// ============================================================================ + +#[test] +fn menu_bar_with_custom_spacing() -> Result<(), Error> { + let (mut app, _) = App::new(|| { + menu_bar!((Text::new("File")), (Text::new("Edit"))) + .spacing(20.0) + .into() + }); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + let mut ui = simulator(&app); + assert!( + ui.find("File").is_ok(), + "Should find File with custom spacing" + ); + assert!( + ui.find("Edit").is_ok(), + "Should find Edit with custom spacing" + ); + + Ok(()) +} + +#[test] +fn menu_bar_with_custom_padding() -> Result<(), Error> { + let (mut app, _) = App::new(|| menu_bar!((Text::new("File"))).padding(10.0).into()); + + let ui = simulator(&app); + for message in ui.into_messages() { + app.update(message); + } + + let mut ui = simulator(&app); + assert!( + ui.find("File").is_ok(), + "Should find File with custom padding" + ); + + Ok(()) +} diff --git a/tests/number_input_integration_tests.rs b/tests/number_input_integration_tests.rs new file mode 100644 index 00000000..bc356a3f --- /dev/null +++ b/tests/number_input_integration_tests.rs @@ -0,0 +1,341 @@ +//! Integration tests for the NumberInput widget +//! +//! These tests verify the NumberInput widget's behavior and public API +//! from an external perspective, testing the widget as a user of the +//! library would interact with it. + +// Test Notes: +// Button cheat sheet +// cancel → \u{e800} // Used for close/cancel buttons +// down_open → \u{e801} // Down arrow (used in number_input, time_picker) +// left_open → \u{e802} // Left arrow (used in date_picker navigation) +// right_open → \u{e803} // Right arrow (used in date_picker navigation) +// up_open → \u{e804} // Up arrow (used in number_input, time_picker) +// ok → \u{e805} // Checkmark/submit (used in pickers) + +// Simulator API https://raw.githubusercontent.com/iced-rs/iced/master/test/src/simulator.rs + +use iced::{Element, Settings}; +use iced_aw::NumberInput; +use iced_test::{Error, Simulator}; + +#[derive(Clone, Debug)] +enum Message { + Changed(#[allow(dead_code)] u32), + Submit, +} + +type ViewFn = Box Element<'static, Message>>; + +#[derive(Clone)] +struct App { + view_fn: std::rc::Rc, +} + +impl App { + fn new(view_fn: F) -> (Self, iced::Task) + where + F: Fn() -> Element<'static, Message> + 'static, + { + ( + App { + view_fn: std::rc::Rc::new(Box::new(view_fn)), + }, + iced::Task::none(), + ) + } + + fn update(&mut self, message: Message) { + match message { + Message::Changed(_) | Message::Submit => { + // No state changes in these tests + } + } + } + + fn view(&self) -> Element<'_, Message> { + (self.view_fn)() + } +} + +fn simulator(app: &App) -> Simulator<'_, Message> { + Simulator::with_settings( + Settings { + ..Settings::default() + }, + app.view(), + ) +} + +// ============================================================================ +// Display and Finding Elements Tests +// ============================================================================ + +#[test] +fn can_find_number_input_value() -> Result<(), Error> { + let value = 42u32; + + let (mut app, _) = App::new(move || NumberInput::new(&value, 0..=100, Message::Changed).into()); + let ui = simulator(&app); + + for message in ui.into_messages() { + app.update(message); + } + + // Create a new simulator to verify the rendered content + let mut ui = simulator(&app); + assert!( + ui.find("42").is_ok(), + "Number input value should be findable" + ); + + Ok(()) +} + +#[test] +fn displays_correct_initial_value() -> Result<(), Error> { + let value = 100u32; + + let (app, _) = App::new(move || NumberInput::new(&value, 0..=200, Message::Changed).into()); + + let mut ui = simulator(&app); + assert!(ui.find("100").is_ok(), "Initial value should be displayed"); + + Ok(()) +} + +#[test] +fn can_find_increment_button() -> Result<(), Error> { + let value = 50u32; + + let (app, _) = App::new(move || NumberInput::new(&value, 0..=100, Message::Changed).into()); + + let mut ui = simulator(&app); + // The up arrow button should be findable (with spaces around it: " ▲ " or " + ") + assert!( + ui.find(" ▲ ").is_ok() || ui.find(" + ").is_ok(), + "Increment button should be findable" + ); + + Ok(()) +} + +#[test] +fn can_find_decrement_button() -> Result<(), Error> { + let value = 50u32; + + let (app, _) = App::new(move || NumberInput::new(&value, 0..=100, Message::Changed).into()); + + let mut ui = simulator(&app); + // The down arrow button should be findable (with spaces around it: " ▼ " or " - ") + assert!( + ui.find(" ▼ ").is_ok() || ui.find(" - ").is_ok(), + "Decrement button should be findable" + ); + + Ok(()) +} + +// ============================================================================ +// Button Click Tests +// ============================================================================ + +#[test] +fn increment_button_click_produces_message() -> Result<(), Error> { + let value = 50u32; + + let (mut app, _) = App::new(move || NumberInput::new(&value, 0..=100, Message::Changed).into()); + + let mut ui = simulator(&app); + + // Find and click the increment button (with spaces) + if ui.find(" ▲ ").is_ok() { + ui.click(" ▲ ")?; + } else { + ui.click(" + ")?; + } + + // Verify we got a Changed message + let mut got_changed = false; + for message in ui.into_messages() { + if matches!(message, Message::Changed(_)) { + got_changed = true; + } + app.update(message); + } + + assert!( + got_changed, + "Increment button should produce Message::Changed" + ); + + Ok(()) +} + +#[test] +fn decrement_button_click_produces_message() -> Result<(), Error> { + let value = 50u32; + + let (mut app, _) = App::new(move || NumberInput::new(&value, 0..=100, Message::Changed).into()); + + let mut ui = simulator(&app); + + // Find and click the decrement button (with spaces) + if ui.find(" ▼ ").is_ok() { + ui.click(" ▼ ")?; + } else { + ui.click(" - ")?; + } + + // Verify we got a Changed message + let mut got_changed = false; + for message in ui.into_messages() { + if matches!(message, Message::Changed(_)) { + got_changed = true; + } + app.update(message); + } + + assert!( + got_changed, + "Decrement button should produce Message::Changed" + ); + + Ok(()) +} + +// ============================================================================ +// Boundary Tests +// ============================================================================ + +#[test] +fn cannot_increment_past_max() -> Result<(), Error> { + let value = 100u32; + + let (app, _) = App::new(move || NumberInput::new(&value, 0..=100, Message::Changed).into()); + + let mut ui = simulator(&app); + + // Verify at max + assert!(ui.find("100").is_ok(), "Should be at max value"); + + // Try to click increment (value should remain at 100) + // Note: The button may still be clickable but won't change the value + if ui.find(" ▲ ").is_ok() { + ui.click(" ▲ ")?; + } else if ui.find(" + ").is_ok() { + ui.click(" + ")?; + } + + // Value should still be 100 + assert!( + ui.find("100").is_ok(), + "Value should remain at max after clicking increment" + ); + + Ok(()) +} + +#[test] +fn cannot_decrement_past_min() -> Result<(), Error> { + let value = 0u32; + + let (app, _) = App::new(move || NumberInput::new(&value, 0..=100, Message::Changed).into()); + + let mut ui = simulator(&app); + + // Verify at min + assert!(ui.find("0").is_ok(), "Should be at min value"); + + // Try to click decrement (value should remain at 0) + if ui.find(" ▼ ").is_ok() { + ui.click(" ▼ ")?; + } else if ui.find(" - ").is_ok() { + ui.click(" - ")?; + } + + // Value should still be 0 + assert!( + ui.find("0").is_ok(), + "Value should remain at min after clicking decrement" + ); + + Ok(()) +} + +// ============================================================================ +// On Submit Tests +// ============================================================================ + +#[test] +fn on_submit_produces_message() -> Result<(), Error> { + let value = 50u32; + + let (mut app, _) = App::new(move || { + NumberInput::new(&value, 0..=100, Message::Changed) + .on_submit(Message::Submit) + .into() + }); + + let mut ui = simulator(&app); + + // Click on the value to focus the input + ui.click("50")?; + + // Press enter + ui.tap_key(iced::keyboard::Key::Named( + iced::keyboard::key::Named::Enter, + )); + + // Verify we got a Submit message + let mut got_submit = false; + for message in ui.into_messages() { + if matches!(message, Message::Submit) { + got_submit = true; + } + app.update(message); + } + + assert!(got_submit, "Enter key should produce Message::Submit"); + + Ok(()) +} + +// ============================================================================ +// Different Value Types Tests +// ============================================================================ + +#[test] +fn works_with_i32() -> Result<(), Error> { + let value = -10i32; + + let (app, _) = App::new(move || { + // Use a closure that converts i32 to our Message type for testing + iced_aw::NumberInput::new(&value, -100..=100, |_v| Message::Submit).into() + }); + + let ui = simulator(&app); + + // Just verify it renders without errors + for _message in ui.into_messages() { + // Process messages + } + + Ok(()) +} + +#[test] +fn works_with_f64() -> Result<(), Error> { + let value = 2.5f64; + + // Create a simple widget element without using the full App infrastructure + // This test just verifies the widget can be constructed with f64 + let _widget: iced_aw::NumberInput<'_, f64, (), iced::Theme, iced::Renderer> = + iced_aw::NumberInput::new(&value, 0.0..=10.0, |v| { + // Use a placeholder closure for testing + let _ = v; + }); + + // If we got here without panic, the test passes + Ok(()) +}