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:
Simon Hausmann 2024-06-27 17:05:58 +02:00 committed by GitHub
parent f4085cfd13
commit b45945a234
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 326 additions and 26 deletions

View file

@ -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();
}

View file

@ -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();

View file

@ -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();

View file

@ -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.

View file

@ -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]

View file

@ -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;

View file

@ -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]

View file

@ -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(),
},
}
}
}

View 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();
}

View file

@ -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();
///

View file

@ -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 {