use crate::ferro_headless_platform::FerroHeadlessPlatform;
use crate::{
FerroHeadlessPlatformExtensions, FerroHeadlessPlatformOptions, FerroTestApplicationAttribute, FerroTestAssembly,
FerroTestIsolationAttribute, FerroTestIsolationLevel, HeadlessUnitTestSession, HeadlessUnitTestTask,
};
use ferroui_base::threading::{CancellationToken, Dispatcher};
use ferroui_base::{ferro_class, ferro_impl_classes, instantiate, FerroObjectImpl, Ref};
use ferroui_controls::{AppBuilder, Application, ApplicationImpl, NewApplication};
use ferroui_harfbuzz::HarfBuzzApplicationExtensions;
use ferroui_skia::SkiaApplicationExtensions;
use ferroui_themes_simple::SimpleTheme;
use std::cell::Cell;
use std::collections::HashMap;
use std::future::Future;
use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, Once, OnceLock, PoisonError};
#[repr(C)]
pub(crate) struct TestApplication {
base: Application,
}
ferro_class!(TestApplication: Application);
ferro_impl_classes!(TestApplication: FerroObjectImpl);
impl NewApplication for TestApplication {
fn new_application() -> Ref<Self> {
let this = instantiate(Self { base: Application::construct() });
this.styles().add(SimpleTheme::new().as_style());
this
}
}
impl ApplicationImpl for TestApplication {}
ferroui_base::ferro_thread_local! {
static ISOLATION_LEVEL: Cell<FerroTestIsolationLevel> = const { Cell::new(FerroTestIsolationLevel::PerTest) };
}
impl TestApplication {
pub(crate) fn isolation_level() -> FerroTestIsolationLevel {
ISOLATION_LEVEL.get()
}
pub(crate) fn uses_shared_mouse_device() -> bool {
Self::isolation_level() == FerroTestIsolationLevel::PerTest
}
pub(crate) fn build_ferro_app() -> AppBuilder {
AppBuilder::configure::<TestApplication>().use_harfbuzz().use_skia().use_headless(FerroHeadlessPlatformOptions {
use_headless_drawing: false,
overlay_popups: false,
use_shared_mouse_device: Some(Self::uses_shared_mouse_device()),
..FerroHeadlessPlatformOptions::default()
})
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct HeadlessRuntimeOptions {
pub(crate) supports_window_positioning: Option<bool>,
pub(crate) use_logical_desktop_coordinates: Option<bool>,
}
impl TestApplication {
pub(crate) fn reconfigure(options: HeadlessRuntimeOptions) -> RestoreScope {
let current = FerroHeadlessPlatform::options().expect("the headless platform is initialised");
let previous = {
let current = current.borrow();
HeadlessRuntimeOptions {
supports_window_positioning: Some(current.supports_window_positioning),
use_logical_desktop_coordinates: Some(current.use_logical_desktop_coordinates),
}
};
Self::apply(options);
RestoreScope { previous: Cell::new(Some(previous)) }
}
fn apply(options: HeadlessRuntimeOptions) {
let Some(current) = FerroHeadlessPlatform::options() else {
return;
};
let mut current = current.borrow_mut();
if let Some(supports_window_positioning) = options.supports_window_positioning {
current.supports_window_positioning = supports_window_positioning;
}
if let Some(use_logical_desktop_coordinates) = options.use_logical_desktop_coordinates {
current.use_logical_desktop_coordinates = use_logical_desktop_coordinates;
}
}
}
pub(crate) struct RestoreScope {
previous: Cell<Option<HeadlessRuntimeOptions>>,
}
impl Drop for RestoreScope {
fn drop(&mut self) {
if let Some(previous) = self.previous.take() {
TestApplication::apply(previous);
}
}
}
fn build_per_test_app() -> AppBuilder {
ISOLATION_LEVEL.set(FerroTestIsolationLevel::PerTest);
TestApplication::build_ferro_app()
}
fn build_per_assembly_app() -> AppBuilder {
ISOLATION_LEVEL.set(FerroTestIsolationLevel::PerAssembly);
TestApplication::build_ferro_app()
}
pub(crate) static PER_TEST: FerroTestAssembly = FerroTestAssembly {
name: "ferroui-headless-per-test-unit-tests",
test_application: Some(FerroTestApplicationAttribute::new(build_per_test_app)),
test_isolation: Some(FerroTestIsolationAttribute::new(FerroTestIsolationLevel::PerTest)),
};
pub(crate) static PER_ASSEMBLY: FerroTestAssembly = FerroTestAssembly {
name: "ferroui-headless-per-assembly-unit-tests",
test_application: Some(FerroTestApplicationAttribute::new(build_per_assembly_app)),
test_isolation: Some(FerroTestIsolationAttribute::new(FerroTestIsolationLevel::PerAssembly)),
};
ferroui_base::ferro_thread_local! {
static CURRENT_TEST: Cell<u64> = const { Cell::new(0) };
}
static NEXT_TEST: AtomicU64 = AtomicU64::new(1);
fn session_panics() -> &'static Mutex<HashMap<u64, String>> {
static PANICS: OnceLock<Mutex<HashMap<u64, String>>> = OnceLock::new();
static HOOK: Once = Once::new();
HOOK.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let test = CURRENT_TEST.try_with(Cell::get).unwrap_or(0);
if test != 0 {
let mut panics = session_panics().lock().unwrap_or_else(PoisonError::into_inner);
let messages = panics.entry(test).or_default();
if !messages.is_empty() {
messages.push_str("\nthen ");
}
messages.push_str(&info.to_string());
}
previous(info);
}));
});
PANICS.get_or_init(Mutex::default)
}
fn wait(task: HeadlessUnitTestTask<()>, test: u64) {
match catch_unwind(AssertUnwindSafe(|| task.wait())) {
Ok(result) => result.expect("the test was cancelled"),
Err(payload) => {
let message = session_panics().lock().unwrap_or_else(PoisonError::into_inner).remove(&test);
match message {
Some(message) => panic!("{message}"),
None => resume_unwind(payload),
}
}
}
}
pub(crate) fn run(assembly: &'static FerroTestAssembly, test: impl FnOnce() + Send + 'static) {
session_panics();
let session = HeadlessUnitTestSession::get_or_start_for_assembly(Some(assembly));
let number = NEXT_TEST.fetch_add(1, Ordering::Relaxed);
let task = session.dispatch(
move || {
CURRENT_TEST.set(number);
let dispatcher = Dispatcher::ui_thread();
test();
dispatcher.run_jobs(None);
},
CancellationToken::none(),
);
wait(task, number);
}
pub(crate) fn run_async<Fut: Future<Output = ()> + 'static>(
assembly: &'static FerroTestAssembly,
test: impl FnOnce() -> Fut + Send + 'static,
) {
session_panics();
let session = HeadlessUnitTestSession::get_or_start_for_assembly(Some(assembly));
let number = NEXT_TEST.fetch_add(1, Ordering::Relaxed);
let task = session.dispatch_task(
move || {
CURRENT_TEST.set(number);
async move {
let dispatcher = Dispatcher::ui_thread();
test().await;
dispatcher.run_jobs(None);
}
},
CancellationToken::none(),
);
wait(task, number);
}
macro_rules! ferro_fact {
($(#[$attribute:meta])* $name:ident) => {
$crate::unit_tests::test_application::ferro_fact!(@tests run, $name, [$(#[$attribute])*], []);
};
($(#[$attribute:meta])* async $name:ident) => {
$crate::unit_tests::test_application::ferro_fact!(@tests run_async, $name, [$(#[$attribute])*], []);
};
(@tests $run:ident, $name:ident, [$(#[$attribute:meta])*], [$(#[$shared:meta])*]) => {
mod $name {
use $crate::unit_tests::test_application::{$run, PER_ASSEMBLY, PER_TEST};
#[test]
$(#[$attribute])*
fn per_test() {
$run(&PER_TEST, super::$name);
}
#[test]
$(#[$attribute])*
$(#[$shared])*
fn per_assembly() {
$run(&PER_ASSEMBLY, super::$name);
}
}
};
}
macro_rules! ferro_theory {
($name:ident { $($row:ident: ($($argument:expr),*)),* $(,)? }) => {
$crate::unit_tests::test_application::ferro_theory!(@tests run, $name, [], $($row: ($($argument),*)),*);
};
(async $name:ident { $($row:ident: ($($argument:expr),*)),* $(,)? }) => {
$crate::unit_tests::test_application::ferro_theory!(@tests run_async, $name, [], $($row: ($($argument),*)),*);
};
(@tests $run:ident, $name:ident, $shared:tt, $($row:ident: ($($argument:expr),*)),*) => {
mod $name {
mod per_test {
use $crate::unit_tests::test_application::{$run, PER_TEST};
$(
#[test]
fn $row() {
$run(&PER_TEST, || super::super::$name($($argument),*));
}
)*
}
mod per_assembly {
use $crate::unit_tests::test_application::{$run, PER_ASSEMBLY};
$(
$crate::unit_tests::test_application::ferro_theory!(@row $run, $name, $shared, $row: ($($argument),*));
)*
}
}
};
(@row $run:ident, $name:ident, [$(#[$shared:meta])*], $row:ident: ($($argument:expr),*)) => {
#[test]
$(#[$shared])*
fn $row() {
$run(&PER_ASSEMBLY, || super::super::$name($($argument),*));
}
};
}
pub(crate) use {ferro_fact, ferro_theory};