Skip to content

Commit 20a53b7

Browse files
committed
Async Tests
1 parent 993079d commit 20a53b7

File tree

6 files changed

+401
-59
lines changed

6 files changed

+401
-59
lines changed

godot-macros/src/itest.rs

+48-8
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,21 @@ pub fn attribute_itest(input_item: venial::Item) -> ParseResult<TokenStream> {
1717
_ => return bail!(&input_item, "#[itest] can only be applied to functions"),
1818
};
1919

20+
let mut attr = KvParser::parse_required(&func.attributes, "itest", &func.name)?;
21+
let skipped = attr.handle_alone("skip")?;
22+
let focused = attr.handle_alone("focus")?;
23+
let is_async = attr.handle_alone("async")?;
24+
attr.finish()?;
25+
2026
// Note: allow attributes for things like #[rustfmt] or #[clippy]
2127
if func.generic_params.is_some()
2228
|| func.params.len() > 1
23-
|| func.return_ty.is_some()
29+
|| (func.return_ty.is_some() && !is_async)
2430
|| func.where_clause.is_some()
2531
{
2632
return bad_signature(&func);
2733
}
2834

29-
let mut attr = KvParser::parse_required(&func.attributes, "itest", &func.name)?;
30-
let skipped = attr.handle_alone("skip")?;
31-
let focused = attr.handle_alone("focus")?;
32-
attr.finish()?;
33-
3435
if skipped && focused {
3536
return bail!(
3637
func.name,
@@ -47,24 +48,53 @@ pub fn attribute_itest(input_item: venial::Item) -> ParseResult<TokenStream> {
4748
// Correct parameter type (crude macro check) -> reuse parameter name
4849
if path_ends_with(&param.ty.tokens, "TestContext") {
4950
param.to_token_stream()
51+
} else if is_async {
52+
return bad_async_signature(&func);
5053
} else {
5154
return bad_signature(&func);
5255
}
56+
} else if is_async {
57+
return bad_async_signature(&func);
5358
} else {
5459
return bad_signature(&func);
5560
}
5661
} else {
5762
quote! { __unused_context: &crate::framework::TestContext }
5863
};
5964

65+
if is_async
66+
&& func
67+
.return_ty
68+
.as_ref()
69+
.and_then(|return_ty| return_ty.as_path())
70+
.and_then(|mut ty| ty.segments.pop())
71+
.map_or(true, |segment| segment.ident != "TaskHandle")
72+
{
73+
return bad_async_signature(&func);
74+
}
75+
6076
let body = &func.body;
6177

78+
let (test_fn_return, plugin_name, test_case_ty) = if is_async {
79+
(
80+
quote!(::godot::builtin::TaskHandle),
81+
quote!(__GODOT_ASYNC_ITEST),
82+
quote!(crate::framework::AsyncRustTestCase),
83+
)
84+
} else {
85+
(
86+
quote!(()),
87+
quote!(__GODOT_ITEST),
88+
quote!(crate::framework::RustTestCase),
89+
)
90+
};
91+
6292
Ok(quote! {
63-
pub fn #test_name(#param) {
93+
pub fn #test_name(#param) -> #test_fn_return {
6494
#body
6595
}
6696

67-
::godot::sys::plugin_add!(__GODOT_ITEST in crate::framework; crate::framework::RustTestCase {
97+
::godot::sys::plugin_add!(#plugin_name in crate::framework; #test_case_ty {
6898
name: #test_name_str,
6999
skipped: #skipped,
70100
focused: #focused,
@@ -84,3 +114,13 @@ fn bad_signature(func: &venial::Function) -> Result<TokenStream, venial::Error>
84114
f = func.name,
85115
)
86116
}
117+
118+
fn bad_async_signature(func: &venial::Function) -> Result<TokenStream, venial::Error> {
119+
bail!(
120+
func,
121+
"#[itest(async)] function must have one of these signatures:\
122+
\n fn {f}() -> TaskHandle {{ ... }}\
123+
\n fn {f}(ctx: &TestContext) -> TaskHandle {{ ... }}",
124+
f = func.name,
125+
)
126+
}

itest/godot/TestRunner.gd

+11-7
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,24 @@ func _ready():
6666

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

69-
var success: bool = rust_runner.run_all_tests(
69+
# Run benchmarks after all synchronous and asynchronous tests have completed.
70+
var run_benchmarks = func (success: bool):
71+
if success:
72+
rust_runner.run_all_benchmarks(self)
73+
74+
var exit_code: int = 0 if success else 1
75+
get_tree().quit(exit_code)
76+
77+
rust_runner.run_all_tests(
7078
gdscript_tests,
7179
gdscript_suites.size(),
7280
allow_focus,
7381
self,
7482
filters,
75-
property_tests
83+
property_tests,
84+
run_benchmarks
7685
)
7786

78-
if success:
79-
rust_runner.run_all_benchmarks(self)
80-
81-
var exit_code: int = 0 if success else 1
82-
get_tree().quit(exit_code)
8387

8488

8589
class GDScriptTestCase:
+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) godot-rust; Bromeon and contributors.
3+
* This Source Code Form is subject to the terms of the Mozilla Public
4+
* License, v. 2.0. If a copy of the MPL was not distributed with this
5+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
6+
*/
7+
8+
use godot::builtin::godot_task;
9+
use godot::builtin::Signal;
10+
use godot::classes::Object;
11+
use godot::obj::NewAlloc;
12+
13+
use crate::framework::itest;
14+
use crate::framework::TestContext;
15+
16+
async fn call_async_fn(signal: Signal) -> u8 {
17+
let value = 5;
18+
19+
let _: () = signal.to_future().await;
20+
21+
value + 5
22+
}
23+
24+
#[itest(async)]
25+
fn start_async_task(ctx: &TestContext) -> TaskHandle {
26+
let tree = ctx.scene_tree.get_tree().unwrap();
27+
let signal = Signal::from_object_signal(&tree, "process_frame");
28+
29+
godot_task(async move {
30+
let inner_signal = signal.clone();
31+
godot_task(async move {
32+
let _: () = inner_signal.to_future().await;
33+
});
34+
35+
let result = call_async_fn(signal.clone()).await;
36+
37+
assert_eq!(result, 10);
38+
})
39+
}
40+
41+
#[itest]
42+
fn cancel_async_task(ctx: &TestContext) {
43+
let tree = ctx.scene_tree.get_tree().unwrap();
44+
let signal = Signal::from_object_signal(&tree, "process_frame");
45+
46+
let handle = godot_task(async move {
47+
let _: () = signal.to_future().await;
48+
49+
unreachable!();
50+
});
51+
52+
handle.cancel();
53+
}
54+
55+
#[itest(async)]
56+
fn async_task_guaranteed_signal_future() -> TaskHandle {
57+
let mut obj = Object::new_alloc();
58+
59+
let signal = Signal::from_object_signal(&obj, "script_changed");
60+
61+
let handle = godot_task(async move {
62+
let result: Option<()> = signal.to_guaranteed_future().await;
63+
64+
assert!(result.is_none());
65+
});
66+
67+
obj.call_deferred("free", &[]);
68+
69+
handle
70+
}

itest/rust/src/engine_tests/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
66
*/
77

8+
#[cfg(since_api = "4.2")]
9+
mod async_test;
810
mod codegen_enums_test;
911
mod codegen_test;
1012
mod engine_enum_test;

itest/rust/src/framework/mod.rs

+46-3
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ pub use godot::test::{bench, itest};
2424

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

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

53-
(tests, all_files.len(), is_focus_run)
55+
(tests, all_files, is_focus_run)
56+
}
57+
58+
/// Finds all `#[itest(async)]` tests.
59+
#[cfg(since_api = "4.2")]
60+
fn collect_async_rust_tests(filters: &[String]) -> (Vec<AsyncRustTestCase>, HashSet<&str>, bool) {
61+
let mut all_files = HashSet::new();
62+
let mut tests = vec![];
63+
let mut is_focus_run = false;
64+
65+
sys::plugin_foreach!(__GODOT_ASYNC_ITEST; |test: &AsyncRustTestCase| {
66+
// First time a focused test is encountered, switch to "focused" mode and throw everything away.
67+
if !is_focus_run && test.focused {
68+
tests.clear();
69+
all_files.clear();
70+
is_focus_run = true;
71+
}
72+
73+
// Only collect tests if normal mode, or focus mode and test is focused.
74+
if (!is_focus_run || test.focused) && passes_filter(filters, test.name) {
75+
all_files.insert(test.file);
76+
tests.push(*test);
77+
}
78+
});
79+
80+
// Sort alphabetically for deterministic run order
81+
tests.sort_by_key(|test| test.file);
82+
83+
(tests, all_files, is_focus_run)
5484
}
5585

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

72102
// ----------------------------------------------------------------------------------------------------------------------------------------------
73103
// Shared types
74-
104+
#[derive(Clone)]
75105
pub struct TestContext {
76106
pub scene_tree: Gd<Node>,
77107
pub property_tests: Gd<Node>,
@@ -108,6 +138,19 @@ pub struct RustTestCase {
108138
pub function: fn(&TestContext),
109139
}
110140

141+
#[cfg(since_api = "4.2")]
142+
#[derive(Copy, Clone)]
143+
pub struct AsyncRustTestCase {
144+
pub name: &'static str,
145+
pub file: &'static str,
146+
pub skipped: bool,
147+
/// If one or more tests are focused, only they will be executed. Helpful for debugging and working on specific features.
148+
pub focused: bool,
149+
#[allow(dead_code)]
150+
pub line: u32,
151+
pub function: fn(&TestContext) -> godot::builtin::TaskHandle,
152+
}
153+
111154
#[derive(Copy, Clone)]
112155
pub struct RustBenchmark {
113156
pub name: &'static str,

0 commit comments

Comments
 (0)