mirror of
https://github.com/slint-ui/slint.git
synced 2025-08-04 18:58:36 +00:00
Add support for async unit testing and element handle click events (#5499)
This patch adds async click functions to ElementHandle and adds timer support to the testing backend's event loop.
This commit is contained in:
parent
f4085cfd13
commit
b45945a234
11 changed files with 326 additions and 26 deletions
|
@ -80,5 +80,5 @@ pub fn set_quit_on_last_window_closed(
|
|||
#[napi]
|
||||
pub fn init_testing() {
|
||||
#[cfg(feature = "testing")]
|
||||
i_slint_backend_testing::init_integration_test();
|
||||
i_slint_backend_testing::init_integration_test_with_mock_time();
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ use ::slint::slint;
|
|||
|
||||
#[test]
|
||||
fn show_maintains_strong_reference() {
|
||||
i_slint_backend_testing::init_integration_test();
|
||||
i_slint_backend_testing::init_integration_test_with_mock_time();
|
||||
|
||||
slint!(export component TestWindow inherits Window {
|
||||
callback root-clicked();
|
||||
|
|
|
@ -39,7 +39,7 @@ mod executor {
|
|||
|
||||
#[test]
|
||||
fn main() {
|
||||
i_slint_backend_testing::init_integration_test();
|
||||
i_slint_backend_testing::init_integration_test_with_mock_time();
|
||||
|
||||
slint::invoke_from_event_loop(|| {
|
||||
let handle = slint::spawn_local(async { String::from("Hello") }).unwrap();
|
||||
|
@ -58,7 +58,9 @@ fn main() {
|
|||
#[test]
|
||||
fn with_context() {
|
||||
use i_slint_core::SlintContext;
|
||||
let ctx = SlintContext::new(Box::new(i_slint_backend_testing::TestingBackend::new()));
|
||||
let ctx = SlintContext::new(Box::new(i_slint_backend_testing::TestingBackend::new(
|
||||
i_slint_backend_testing::TestingBackendOptions { mock_time: true, threading: true },
|
||||
)));
|
||||
let handle = ctx.spawn_local(async { String::from("Hello") }).unwrap();
|
||||
ctx.spawn_local(async move { panic!("Aborted task") }).unwrap().abort();
|
||||
let handle2 = ctx.spawn_local(async move { handle.await + ", World" }).unwrap();
|
||||
|
|
|
@ -33,12 +33,15 @@ For automated testing in CI environments without a windowing system / display, i
|
|||
desirable to run tests. The Slint Testing Backend simulates a windowing system without requiring one:
|
||||
No pixels are rendered and text is measured by a fixed font size.
|
||||
|
||||
Use [`init_integration_test()`] for [integration tests](https://doc.rust-lang.org/rust-by-example/testing/integration_testing.html)
|
||||
Use [`init_integration_test_with_system_time()`] for [integration tests](https://doc.rust-lang.org/rust-by-example/testing/integration_testing.html)
|
||||
where your test code requires Slint to provide an event loop, for example when spawning threads
|
||||
and calling `slint::invoke_from_event_loop()`.
|
||||
and calling `slint::invoke_from_event_loop()`. If you want to not only simulate the windowing system but
|
||||
also the system time, use [`init_integration_test_with_mock_time()`] to initialize the backend and then
|
||||
call [`mock_elapsed_time()`] to advance animations and move timers closer to their next timeout.
|
||||
|
||||
Use [`init_no_event_loop()`] for [unit tests](https://doc.rust-lang.org/rust-by-example/testing/unit_testing.html) when your test
|
||||
code does not require an event loop.
|
||||
code does not require an event loop. Note that system time is also mocked in this scenario, so use
|
||||
[`mock_elapsed_time()`] to advance animations and timers.
|
||||
|
||||
## Preliminary User Interface Testing API
|
||||
|
||||
|
@ -133,3 +136,61 @@ fn test_basic_user_interface()
|
|||
assert!(*submitted.borrow());
|
||||
}
|
||||
```
|
||||
|
||||
## Simulating events / Asynchronous testing
|
||||
|
||||
When testing user interfaces it may be desirable to not only invoke accessible actions on elements, but it may also be
|
||||
useful to simulate touch or mouse input. For example a mouse click on a button is a sequence:
|
||||
|
||||
1. An initial mouse move event to a location over the button
|
||||
2. A mouse press event.
|
||||
3. In real life, a certain amount of time would elapse now.
|
||||
4. Finally, the user lifts the finger again from the mouse and a mouse release event is triggered.
|
||||
|
||||
To simulate this behaviour, [`ElementHandle`] provides functions such as [`ElementHandle::single_click()`], [`ElementHandle::double_click()`] or [`ElementHandle::right_click()`].
|
||||
Since these functions simulate a sequence of events with a period of idle time between the events, these functions are [async](https://doc.rust-lang.org/std/keyword.async.html)
|
||||
and return a [`std::future::Future`], which resolves when the last event in the sequence was sent.
|
||||
|
||||
Calling these functions requires running the test function itself as a future and running an event loop in the background.
|
||||
This can be accomplished using `slint::spawn_local()`, `slint::run_event_loop()`, and `slint::quit_event_loop()`. The following
|
||||
example wraps the core functions for testing in an async closure:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_click() {
|
||||
i_slint_backend_testing::init_integration_test_with_system_time();
|
||||
|
||||
slint::spawn_local(async move {
|
||||
slint::slint! {
|
||||
export component App inherits Window {
|
||||
out property <int> click-count: 0;
|
||||
ta := TouchArea {
|
||||
clicked => { root.click-count += 1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app = App::new().unwrap();
|
||||
|
||||
let mut it = ElementHandle::find_by_element_id(&app, "App::ta");
|
||||
let elem = it.next().unwrap();
|
||||
assert!(it.next().is_none());
|
||||
|
||||
assert_eq!(app.get_click_count(), 0);
|
||||
elem.single_click().await;
|
||||
assert_eq!(app.get_click_count(), 1);
|
||||
|
||||
slint::quit_event_loop().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
slint::run_event_loop().unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
After initializing the testing backend with support for using the system time, an async
|
||||
closure is spawned, which does the actual testing. In the subsequent `run_event_loop()` call,
|
||||
the event loop is started, and that will start polling the async closure passed to `spawn_local()`.
|
||||
|
||||
In this closure we can now call `.await` on the future [`ElementHandle::single_click()`] returns, which
|
||||
will keep running the event loop until the click is complete, and then continue with the test function.
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ impl super::Sealed for RootWrapper<'_> {}
|
|||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn slint_testing_init_backend() {
|
||||
crate::init_integration_test();
|
||||
crate::init_integration_test_with_mock_time();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
|
|
|
@ -23,10 +23,13 @@ pub mod systest;
|
|||
/// an event loop such as `slint::invoke_from_event_loop` or `Timer`s won't work.
|
||||
/// Must be called before any call that would otherwise initialize the rendering backend.
|
||||
/// Calling it when the rendering backend is already initialized will panic.
|
||||
///
|
||||
/// Note that for animations and timers, the changes in the system time will be disregarded.
|
||||
/// Instead, use [`mock_elapsed_time()`] to advance the simulate (mock) time Slint uses.
|
||||
pub fn init_no_event_loop() {
|
||||
i_slint_core::platform::set_platform(
|
||||
Box::new(testing_backend::TestingBackend::new_no_thread()),
|
||||
)
|
||||
i_slint_core::platform::set_platform(Box::new(testing_backend::TestingBackend::new(
|
||||
testing_backend::TestingBackendOptions { mock_time: true, threading: false },
|
||||
)))
|
||||
.expect("platform already initialized");
|
||||
}
|
||||
|
||||
|
@ -35,9 +38,33 @@ pub fn init_no_event_loop() {
|
|||
/// tests with only one `#[test]` function. (Or in a doc test)
|
||||
/// Must be called before any call that would otherwise initialize the rendering backend.
|
||||
/// Calling it when the rendering backend is already initialized will panic.
|
||||
pub fn init_integration_test() {
|
||||
i_slint_core::platform::set_platform(Box::new(testing_backend::TestingBackend::new()))
|
||||
.expect("platform already initialized");
|
||||
///
|
||||
/// Note that for animations and timers, the changes in the system time will be disregarded.
|
||||
/// Instead, use [`mock_elapsed_time()`] to advance the simulate (mock) time Slint uses.
|
||||
pub fn init_integration_test_with_mock_time() {
|
||||
i_slint_core::platform::set_platform(Box::new(testing_backend::TestingBackend::new(
|
||||
testing_backend::TestingBackendOptions { mock_time: true, threading: true },
|
||||
)))
|
||||
.expect("platform already initialized");
|
||||
}
|
||||
|
||||
/// Initialize the testing backend with support for simple event loop.
|
||||
/// This function can only be called once per process, so make sure to use integration
|
||||
/// tests with only one `#[test]` function. (Or in a doc test)
|
||||
/// Must be called before any call that would otherwise initialize the rendering backend.
|
||||
/// Calling it when the rendering backend is already initialized will panic.
|
||||
pub fn init_integration_test_with_system_time() {
|
||||
i_slint_core::platform::set_platform(Box::new(testing_backend::TestingBackend::new(
|
||||
testing_backend::TestingBackendOptions { mock_time: false, threading: true },
|
||||
)))
|
||||
.expect("platform already initialized");
|
||||
}
|
||||
|
||||
/// Advance the simulated mock time by the specified duration. Use in combination with
|
||||
/// [`init_integration_test_with_mock_time()`] or [`init_no_event_loop()`].
|
||||
#[cfg(not(feature = "internal"))]
|
||||
pub fn mock_elapsed_time(duration: std::time::Duration) {
|
||||
i_slint_core::tests::slint_mock_elapsed_time(duration.as_millis() as _);
|
||||
}
|
||||
|
||||
pub use i_slint_core::items::AccessibleRole;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use core::ops::ControlFlow;
|
||||
use i_slint_core::accessibility::{AccessibilityAction, AccessibleStringProperty};
|
||||
use i_slint_core::api::ComponentHandle;
|
||||
use i_slint_core::api::{ComponentHandle, LogicalPosition};
|
||||
use i_slint_core::graphics::euclid;
|
||||
use i_slint_core::item_tree::{ItemTreeRc, ItemVisitorResult, ItemWeak, TraversalOrder};
|
||||
use i_slint_core::items::ItemRc;
|
||||
|
@ -494,6 +494,150 @@ impl ElementHandle {
|
|||
item.accessible_action(&AccessibilityAction::Decrement)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulates a single click (or touch tap) on the element at its center point.
|
||||
pub async fn single_click(&self) {
|
||||
self.click(
|
||||
i_slint_core::platform::PointerEventButton::Left,
|
||||
std::time::Duration::from_millis(50),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Simulates a double click (or touch tap) on the element at its center point.
|
||||
pub async fn double_click(&self) {
|
||||
let Ok(click_interval) = i_slint_core::with_platform(
|
||||
|| Err(i_slint_core::platform::PlatformError::NoPlatform),
|
||||
|platform| Ok(platform.click_interval()),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let Some(duration_recognized_as_double_click) =
|
||||
click_interval.checked_sub(std::time::Duration::from_millis(10))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(single_click_duration) = duration_recognized_as_double_click.checked_div(2) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let button = i_slint_core::platform::PointerEventButton::Left;
|
||||
|
||||
let Some(item) = self.item.upgrade() else { return };
|
||||
let Some(window_adapter) = item.window_adapter() else { return };
|
||||
let window = window_adapter.window();
|
||||
|
||||
let item_pos = self.absolute_position();
|
||||
let item_size = self.size();
|
||||
let position = LogicalPosition::new(
|
||||
item_pos.x + item_size.width / 2.,
|
||||
item_pos.y + item_size.height / 2.,
|
||||
);
|
||||
|
||||
window.dispatch_event(i_slint_core::platform::WindowEvent::PointerMoved { position });
|
||||
window.dispatch_event(i_slint_core::platform::WindowEvent::PointerPressed {
|
||||
position,
|
||||
button,
|
||||
});
|
||||
|
||||
wait_for(single_click_duration).await;
|
||||
|
||||
window.dispatch_event(i_slint_core::platform::WindowEvent::PointerReleased {
|
||||
position,
|
||||
button,
|
||||
});
|
||||
window.dispatch_event(i_slint_core::platform::WindowEvent::PointerPressed {
|
||||
position,
|
||||
button,
|
||||
});
|
||||
|
||||
wait_for(single_click_duration).await;
|
||||
|
||||
window_adapter.window().dispatch_event(
|
||||
i_slint_core::platform::WindowEvent::PointerReleased { position, button },
|
||||
);
|
||||
}
|
||||
|
||||
/// Simulates a mouse right click on the element at its center point.
|
||||
pub async fn right_click(&self) {
|
||||
self.click(
|
||||
i_slint_core::platform::PointerEventButton::Right,
|
||||
std::time::Duration::from_millis(50),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn click(
|
||||
&self,
|
||||
button: i_slint_core::platform::PointerEventButton,
|
||||
duration: std::time::Duration,
|
||||
) {
|
||||
let Some(item) = self.item.upgrade() else { return };
|
||||
let Some(window_adapter) = item.window_adapter() else { return };
|
||||
let window = window_adapter.window();
|
||||
|
||||
let item_pos = self.absolute_position();
|
||||
let item_size = self.size();
|
||||
let position = LogicalPosition::new(
|
||||
item_pos.x + item_size.width / 2.,
|
||||
item_pos.y + item_size.height / 2.,
|
||||
);
|
||||
|
||||
window.dispatch_event(i_slint_core::platform::WindowEvent::PointerMoved { position });
|
||||
window.dispatch_event(i_slint_core::platform::WindowEvent::PointerPressed {
|
||||
position,
|
||||
button,
|
||||
});
|
||||
|
||||
wait_for(duration).await;
|
||||
|
||||
window_adapter.window().dispatch_event(
|
||||
i_slint_core::platform::WindowEvent::PointerReleased { position, button },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for(duration: std::time::Duration) {
|
||||
enum AsyncTimerState {
|
||||
Starting,
|
||||
Waiting(std::task::Waker),
|
||||
Done,
|
||||
}
|
||||
|
||||
let state = std::rc::Rc::new(std::cell::RefCell::new(AsyncTimerState::Starting));
|
||||
|
||||
std::future::poll_fn(move |context| {
|
||||
let mut current_state = state.borrow_mut();
|
||||
match *current_state {
|
||||
AsyncTimerState::Starting => {
|
||||
*current_state = AsyncTimerState::Waiting(context.waker().clone());
|
||||
let state_clone = state.clone();
|
||||
i_slint_core::timers::Timer::single_shot(duration, move || {
|
||||
let mut current_state = state_clone.borrow_mut();
|
||||
match *current_state {
|
||||
AsyncTimerState::Starting => unreachable!(),
|
||||
AsyncTimerState::Waiting(ref waker) => {
|
||||
waker.wake_by_ref();
|
||||
*current_state = AsyncTimerState::Done;
|
||||
}
|
||||
AsyncTimerState::Done => {}
|
||||
}
|
||||
});
|
||||
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
AsyncTimerState::Waiting(ref existing_waker) => {
|
||||
let new_waker = context.waker();
|
||||
if !existing_waker.will_wake(new_waker) {
|
||||
*current_state = AsyncTimerState::Waiting(new_waker.clone());
|
||||
}
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
AsyncTimerState::Done => std::task::Poll::Ready(()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
@ -15,22 +15,26 @@ use std::pin::Pin;
|
|||
use std::rc::Rc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TestingBackendOptions {
|
||||
pub mock_time: bool,
|
||||
pub threading: bool,
|
||||
}
|
||||
|
||||
pub struct TestingBackend {
|
||||
clipboard: Mutex<Option<String>>,
|
||||
queue: Option<Queue>,
|
||||
mock_time: bool,
|
||||
}
|
||||
|
||||
impl TestingBackend {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(options: TestingBackendOptions) -> Self {
|
||||
Self {
|
||||
queue: Some(Queue(Default::default(), std::thread::current())),
|
||||
..Self::new_no_thread()
|
||||
clipboard: Mutex::default(),
|
||||
queue: options.threading.then(|| Queue(Default::default(), std::thread::current())),
|
||||
mock_time: options.mock_time,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_no_thread() -> Self {
|
||||
Self { clipboard: Mutex::default(), queue: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl i_slint_core::platform::Platform for TestingBackend {
|
||||
|
@ -46,8 +50,15 @@ impl i_slint_core::platform::Platform for TestingBackend {
|
|||
}
|
||||
|
||||
fn duration_since_start(&self) -> core::time::Duration {
|
||||
// The slint::testing::mock_elapsed_time updates the animation tick directly
|
||||
core::time::Duration::from_millis(i_slint_core::animations::current_tick().0)
|
||||
if self.mock_time {
|
||||
// The slint::testing::mock_elapsed_time updates the animation tick directly
|
||||
core::time::Duration::from_millis(i_slint_core::animations::current_tick().0)
|
||||
} else {
|
||||
static INITIAL_INSTANT: std::sync::OnceLock<std::time::Instant> =
|
||||
std::sync::OnceLock::new();
|
||||
let the_beginning = *INITIAL_INSTANT.get_or_init(std::time::Instant::now);
|
||||
std::time::Instant::now() - the_beginning
|
||||
}
|
||||
}
|
||||
|
||||
fn set_clipboard_text(&self, text: &str, clipboard: i_slint_core::platform::Clipboard) {
|
||||
|
@ -72,10 +83,16 @@ impl i_slint_core::platform::Platform for TestingBackend {
|
|||
|
||||
loop {
|
||||
let e = queue.0.lock().unwrap().pop_front();
|
||||
if !self.mock_time {
|
||||
i_slint_core::platform::update_timers_and_animations();
|
||||
}
|
||||
match e {
|
||||
Some(Event::Quit) => break Ok(()),
|
||||
Some(Event::Event(e)) => e(),
|
||||
None => std::thread::park(),
|
||||
None => match i_slint_core::platform::duration_until_next_timer_update() {
|
||||
Some(duration) if !self.mock_time => std::thread::park_timeout(duration),
|
||||
_ => std::thread::park(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
42
internal/backends/testing/tests/click.rs
Normal file
42
internal/backends/testing/tests/click.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||
|
||||
use i_slint_backend_testing::ElementHandle;
|
||||
|
||||
#[test]
|
||||
fn test_click() {
|
||||
i_slint_backend_testing::init_integration_test_with_system_time();
|
||||
|
||||
slint::spawn_local(async move {
|
||||
slint::slint! {
|
||||
export component App inherits Window {
|
||||
out property <int> click-count: 0;
|
||||
out property <int> double-click-count: 0;
|
||||
ta := TouchArea {
|
||||
clicked => { root.click-count += 1; }
|
||||
double-clicked => { root.double-click-count += 1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app = App::new().unwrap();
|
||||
|
||||
let mut it = ElementHandle::find_by_element_id(&app, "App::ta");
|
||||
let elem = it.next().unwrap();
|
||||
assert!(it.next().is_none());
|
||||
|
||||
assert_eq!(app.get_click_count(), 0);
|
||||
assert_eq!(app.get_double_click_count(), 0);
|
||||
elem.single_click().await;
|
||||
assert_eq!(app.get_click_count(), 1);
|
||||
assert_eq!(app.get_double_click_count(), 0);
|
||||
|
||||
elem.double_click().await;
|
||||
assert_eq!(app.get_click_count(), 3);
|
||||
assert_eq!(app.get_double_click_count(), 1);
|
||||
|
||||
slint::quit_event_loop().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
slint::run_event_loop().unwrap();
|
||||
}
|
|
@ -157,7 +157,7 @@ unsafe impl<T: Send> Send for JoinHandle<T> {}
|
|||
/// in the future passed to slint::spawn_local.
|
||||
///
|
||||
/// ```rust
|
||||
/// # i_slint_backend_testing::init_integration_test();
|
||||
/// # i_slint_backend_testing::init_integration_test_with_mock_time();
|
||||
/// // In your main function, create a runtime that runs on the other threads
|
||||
/// let tokio_runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
///
|
||||
|
|
|
@ -690,6 +690,13 @@ impl ItemRc {
|
|||
&|item_tree, index| crate::item_focus::step_out_of_node(index, item_tree),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn window_adapter(&self) -> Option<WindowAdapterRc> {
|
||||
let comp_ref_pin = vtable::VRc::borrow_pin(&self.item_tree);
|
||||
let mut result = None;
|
||||
comp_ref_pin.as_ref().window_adapter(false, &mut result);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ItemRc {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue