Skip to content

Commit

Permalink
Async Tests
Browse files Browse the repository at this point in the history
  • Loading branch information
TitanNano committed Feb 14, 2025
1 parent 6933fc2 commit c58b657
Show file tree
Hide file tree
Showing 6 changed files with 363 additions and 59 deletions.
56 changes: 48 additions & 8 deletions godot-macros/src/itest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,53 @@ 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(|return_ty| return_ty.as_path())
.and_then(|mut ty| ty.segments.pop())
.map_or(true, |segment| segment.ident != "TaskHandle")
{
return bad_async_signature(&func);
}

let body = &func.body;

let (test_fn_return, plugin_name, test_case_ty) = if is_async {
(
quote!(::godot::builtin::TaskHandle),
quote!(__GODOT_ASYNC_ITEST),
quote!(crate::framework::AsyncRustTestCase),
)
} else {
(
quote!(()),
quote!(__GODOT_ITEST),
quote!(crate::framework::RustTestCase),
)
};

Ok(quote! {
pub fn #test_name(#param) {
pub fn #test_name(#param) -> #test_fn_return {
#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 +114,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
70 changes: 70 additions & 0 deletions itest/rust/src/engine_tests/async_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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;
use godot::builtin::Signal;
use godot::classes::Object;
use godot::obj::NewAlloc;

use crate::framework::itest;
use crate::framework::TestContext;

async fn call_async_fn(signal: Signal) -> u8 {
let value = 5;

let _: () = signal.to_future().await;

value + 5
}

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

godot_task(async move {
let inner_signal = signal.clone();
godot_task(async move {
let _: () = inner_signal.to_future().await;
});

let result = call_async_fn(signal.clone()).await;

assert_eq!(result, 10);
})
}

#[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
}
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
46 changes: 43 additions & 3 deletions itest/rust/src/framework/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ pub use godot::test::{bench, itest};

// Registers all the `#[itest]` tests and `#[bench]` benchmarks.
sys::plugin_registry!(pub(crate) __GODOT_ITEST: RustTestCase);
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 +51,34 @@ 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.
fn collect_async_rust_tests(filters: &[String]) -> (Vec<AsyncRustTestCase>, HashSet<&str>, bool) {
let mut all_files = HashSet::new();
let mut tests = vec![];
let mut is_focus_run = false;

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 +99,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 +136,18 @@ pub struct RustTestCase {
pub function: fn(&TestContext),
}

#[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 c58b657

Please sign in to comment.