Skip to content

Commit

Permalink
Async Tests
Browse files Browse the repository at this point in the history
  • Loading branch information
TitanNano committed Feb 17, 2025
1 parent aeb7205 commit 14a7148
Show file tree
Hide file tree
Showing 6 changed files with 465 additions and 60 deletions.
54 changes: 45 additions & 9 deletions godot-macros/src/itest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use proc_macro2::TokenStream;
use quote::{quote, ToTokens};

use crate::util::{bail, path_ends_with, KvParser};
use crate::util::{bail, extract_typename, ident, path_ends_with, KvParser};
use crate::ParseResult;

pub fn attribute_itest(input_item: venial::Item) -> ParseResult<TokenStream> {
Expand All @@ -17,20 +17,21 @@ pub fn attribute_itest(input_item: venial::Item) -> ParseResult<TokenStream> {
_ => return bail!(&input_item, "#[itest] can only be applied to functions"),
};

let mut attr = KvParser::parse_required(&func.attributes, "itest", &func.name)?;
let skipped = attr.handle_alone("skip")?;
let focused = attr.handle_alone("focus")?;
let is_async = attr.handle_alone("async")?;
attr.finish()?;

// Note: allow attributes for things like #[rustfmt] or #[clippy]
if func.generic_params.is_some()
|| func.params.len() > 1
|| func.return_ty.is_some()
|| (func.return_ty.is_some() && !is_async)
|| func.where_clause.is_some()
{
return bad_signature(&func);
}

let mut attr = KvParser::parse_required(&func.attributes, "itest", &func.name)?;
let skipped = attr.handle_alone("skip")?;
let focused = attr.handle_alone("focus")?;
attr.finish()?;

if skipped && focused {
return bail!(
func.name,
Expand All @@ -47,24 +48,49 @@ pub fn attribute_itest(input_item: venial::Item) -> ParseResult<TokenStream> {
// Correct parameter type (crude macro check) -> reuse parameter name
if path_ends_with(&param.ty.tokens, "TestContext") {
param.to_token_stream()
} else if is_async {
return bad_async_signature(&func);
} else {
return bad_signature(&func);
}
} else if is_async {
return bad_async_signature(&func);
} else {
return bad_signature(&func);
}
} else {
quote! { __unused_context: &crate::framework::TestContext }
};

if is_async
&& func
.return_ty
.as_ref()
.and_then(extract_typename)
.map_or(true, |segment| segment.ident != "TaskHandle")
{
return bad_async_signature(&func);
}

let body = &func.body;

let (return_tokens, test_case_ty, plugin_name);
if is_async {
return_tokens = quote! { -> ::godot::builtin::TaskHandle };
test_case_ty = quote! { crate::framework::AsyncRustTestCase };
plugin_name = ident("__GODOT_ASYNC_ITEST");
} else {
return_tokens = TokenStream::new();
test_case_ty = quote! { crate::framework::RustTestCase };
plugin_name = ident("__GODOT_ITEST");
};

Ok(quote! {
pub fn #test_name(#param) {
pub fn #test_name(#param) #return_tokens {
#body
}

::godot::sys::plugin_add!(__GODOT_ITEST in crate::framework; crate::framework::RustTestCase {
::godot::sys::plugin_add!(#plugin_name in crate::framework; #test_case_ty {
name: #test_name_str,
skipped: #skipped,
focused: #focused,
Expand All @@ -84,3 +110,13 @@ fn bad_signature(func: &venial::Function) -> Result<TokenStream, venial::Error>
f = func.name,
)
}

fn bad_async_signature(func: &venial::Function) -> Result<TokenStream, venial::Error> {
bail!(
func,
"#[itest(async)] function must have one of these signatures:\
\n fn {f}() -> TaskHandle {{ ... }}\
\n fn {f}(ctx: &TestContext) -> TaskHandle {{ ... }}",
f = func.name,
)
}
18 changes: 11 additions & 7 deletions itest/godot/TestRunner.gd
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,24 @@ func _ready():

var property_tests = load("res://gen/GenPropertyTests.gd").new()

var success: bool = rust_runner.run_all_tests(
# Run benchmarks after all synchronous and asynchronous tests have completed.
var run_benchmarks = func (success: bool):
if success:
rust_runner.run_all_benchmarks(self)

var exit_code: int = 0 if success else 1
get_tree().quit(exit_code)

rust_runner.run_all_tests(
gdscript_tests,
gdscript_suites.size(),
allow_focus,
self,
filters,
property_tests
property_tests,
run_benchmarks
)

if success:
rust_runner.run_all_benchmarks(self)

var exit_code: int = 0 if success else 1
get_tree().quit(exit_code)


class GDScriptTestCase:
Expand Down
83 changes: 83 additions & 0 deletions itest/rust/src/engine_tests/async_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright (c) godot-rust; Bromeon and contributors.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

use godot::builtin::{
godot_task, Callable, GuaranteedSignalFutureResolver, Signal, SignalFuture, Variant,
};
use godot::classes::{Object, RefCounted};
use godot::meta::ToGodot;
use godot::obj::{NewAlloc, NewGd};

use crate::framework::{itest, TestContext};

#[itest(async)]
fn start_async_task() -> TaskHandle {
let mut object = RefCounted::new_gd();
let object_ref = object.clone();
let signal = Signal::from_object_signal(&object, "custom_signal");

object.add_user_signal("custom_signal");

let signal_future: SignalFuture<u8> = signal.to_future();

let task_handle = godot_task(async move {
let result = signal_future.await;

assert_eq!(result, 10);
drop(object_ref);
});

object.emit_signal("custom_signal", &[10.to_variant()]);

task_handle
}

#[itest]
fn cancel_async_task(ctx: &TestContext) {
let tree = ctx.scene_tree.get_tree().unwrap();
let signal = Signal::from_object_signal(&tree, "process_frame");

let handle = godot_task(async move {
let _: () = signal.to_future().await;

unreachable!();
});

handle.cancel();
}

#[itest(async)]
fn async_task_guaranteed_signal_future() -> TaskHandle {
let mut obj = Object::new_alloc();

let signal = Signal::from_object_signal(&obj, "script_changed");

let handle = godot_task(async move {
let result: Option<()> = signal.to_guaranteed_future().await;

assert!(result.is_none());
});

obj.call_deferred("free", &[]);

handle
}

// Test that two callables created from the same future resolver (but cloned) are equal, while they are not equal to an unrelated
// callable.
#[itest]
fn resolver_callabable_equality() {
let resolver = GuaranteedSignalFutureResolver::<u8>::default();

let callable = Callable::from_custom(resolver.clone());
let cloned_callable = Callable::from_custom(resolver.clone());
let unrelated_callable = Callable::from_local_fn("fn", |_| Ok(Variant::nil()));

assert_eq!(callable, cloned_callable);
assert_ne!(callable, unrelated_callable);
assert_ne!(cloned_callable, unrelated_callable);
}
2 changes: 2 additions & 0 deletions itest/rust/src/engine_tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

#[cfg(since_api = "4.2")]
mod async_test;
mod codegen_enums_test;
mod codegen_test;
mod engine_enum_test;
Expand Down
52 changes: 49 additions & 3 deletions itest/rust/src/framework/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ pub use godot::test::{bench, itest};

// Registers all the `#[itest]` tests and `#[bench]` benchmarks.
sys::plugin_registry!(pub(crate) __GODOT_ITEST: RustTestCase);
#[cfg(since_api = "4.2")]
sys::plugin_registry!(pub(crate) __GODOT_ASYNC_ITEST: AsyncRustTestCase);
sys::plugin_registry!(pub(crate) __GODOT_BENCH: RustBenchmark);

/// Finds all `#[itest]` tests.
fn collect_rust_tests(filters: &[String]) -> (Vec<RustTestCase>, usize, bool) {
fn collect_rust_tests(filters: &[String]) -> (Vec<RustTestCase>, HashSet<&str>, bool) {
let mut all_files = HashSet::new();
let mut tests: Vec<RustTestCase> = vec![];
let mut is_focus_run = false;
Expand All @@ -50,7 +52,38 @@ fn collect_rust_tests(filters: &[String]) -> (Vec<RustTestCase>, usize, bool) {
// Sort alphabetically for deterministic run order
tests.sort_by_key(|test| test.file);

(tests, all_files.len(), is_focus_run)
(tests, all_files, is_focus_run)
}

/// Finds all `#[itest(async)]` tests.
#[cfg(since_api = "4.2")]
fn collect_async_rust_tests(
filters: &[String],
sync_focus_run: bool,
) -> (Vec<AsyncRustTestCase>, HashSet<&str>, bool) {
let mut all_files = HashSet::new();
let mut tests = vec![];
let mut is_focus_run = sync_focus_run;

sys::plugin_foreach!(__GODOT_ASYNC_ITEST; |test: &AsyncRustTestCase| {
// First time a focused test is encountered, switch to "focused" mode and throw everything away.
if !is_focus_run && test.focused {
tests.clear();
all_files.clear();
is_focus_run = true;
}

// Only collect tests if normal mode, or focus mode and test is focused.
if (!is_focus_run || test.focused) && passes_filter(filters, test.name) {
all_files.insert(test.file);
tests.push(*test);
}
});

// Sort alphabetically for deterministic run order
tests.sort_by_key(|test| test.file);

(tests, all_files, is_focus_run)
}

/// Finds all `#[bench]` benchmarks.
Expand All @@ -71,7 +104,7 @@ fn collect_rust_benchmarks() -> (Vec<RustBenchmark>, usize) {

// ----------------------------------------------------------------------------------------------------------------------------------------------
// Shared types

#[derive(Clone)]
pub struct TestContext {
pub scene_tree: Gd<Node>,
pub property_tests: Gd<Node>,
Expand Down Expand Up @@ -108,6 +141,19 @@ pub struct RustTestCase {
pub function: fn(&TestContext),
}

#[cfg(since_api = "4.2")]
#[derive(Copy, Clone)]
pub struct AsyncRustTestCase {
pub name: &'static str,
pub file: &'static str,
pub skipped: bool,
/// If one or more tests are focused, only they will be executed. Helpful for debugging and working on specific features.
pub focused: bool,
#[allow(dead_code)]
pub line: u32,
pub function: fn(&TestContext) -> godot::builtin::TaskHandle,
}

#[derive(Copy, Clone)]
pub struct RustBenchmark {
pub name: &'static str,
Expand Down
Loading

0 comments on commit 14a7148

Please sign in to comment.