diff --git a/.gitignore b/.gitignore index 28bf18c16..aa8a3eb36 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,10 @@ target Cargo.lock # Godot -**/.import/ +# .godot needs to be a pattern like this and not a directory, otherwise the negative statements below don't apply **/.godot/** +*.import + # Needed to run projects without having to open the editor first. !**/.godot/extension_list.cfg !**/.godot/global_script_class_cache.cfg diff --git a/examples/dodge-the-creeps/godot/.gitignore b/examples/dodge-the-creeps/godot/.gitignore deleted file mode 100644 index 44155935d..000000000 --- a/examples/dodge-the-creeps/godot/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.godot/ -.import/ -logs/ diff --git a/examples/dodge-the-creeps/godot/.godot/global_script_class_cache.cfg b/examples/dodge-the-creeps/godot/.godot/global_script_class_cache.cfg new file mode 100644 index 000000000..32c239432 --- /dev/null +++ b/examples/dodge-the-creeps/godot/.godot/global_script_class_cache.cfg @@ -0,0 +1 @@ +list=[] diff --git a/godot-codegen/src/class_generator.rs b/godot-codegen/src/class_generator.rs index b3de7e61b..d3efddb18 100644 --- a/godot-codegen/src/class_generator.rs +++ b/godot-codegen/src/class_generator.rs @@ -851,7 +851,7 @@ fn make_return( let variant = Variant::#from_sys_init_method(|return_ptr| { let mut __err = sys::default_call_error(); #varcall_invocation - assert_eq!(__err.error, sys::GDEXTENSION_CALL_OK); + sys::panic_on_call_error(&__err); }); #return_expr } @@ -863,7 +863,7 @@ fn make_return( let mut __err = sys::default_call_error(); let return_ptr = std::ptr::null_mut(); #varcall_invocation - assert_eq!(__err.error, sys::GDEXTENSION_CALL_OK); + sys::panic_on_call_error(&__err); } } (None, Some(RustTy::EngineClass { tokens, .. })) => { diff --git a/godot-codegen/src/tests.rs b/godot-codegen/src/tests.rs index cc3e8d23b..11d3f974d 100644 --- a/godot-codegen/src/tests.rs +++ b/godot-codegen/src/tests.rs @@ -56,7 +56,7 @@ fn test_pascal_conversion() { fn test_snake_conversion() { // More in line with Rust identifiers, and eases recognition of other automation (like enumerator mapping). #[rustfmt::skip] - let mappings = [ + let mappings = [ ("AABB", "aabb"), ("AESContext", "aes_context"), ("AStar3D", "a_star_3d"), diff --git a/godot-core/src/builtin/variant/mod.rs b/godot-core/src/builtin/variant/mod.rs index ea76024e4..154cde761 100644 --- a/godot-core/src/builtin/variant/mod.rs +++ b/godot-core/src/builtin/variant/mod.rs @@ -4,7 +4,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use crate::builtin::GodotString; +use crate::builtin::{GodotString, StringName}; use godot_ffi as sys; use godot_ffi::GodotFfi; use std::{fmt, ptr}; @@ -91,12 +91,46 @@ impl Variant { } } - // TODO test - #[allow(unused_mut)] + /// ⚠️ Calls the specified `method` with the given `args`. + /// + /// Supports `Object` as well as built-ins with methods (e.g. `Array`, `Vector3`, `GodotString`, etc). + /// + /// # Panics + /// * If `self` is not a variant type which supports method calls. + /// * If the method does not exist or the signature is not compatible with the passed arguments. + /// * If the call causes an error. + #[inline] + pub fn call(&self, method: impl Into, args: &[Variant]) -> Variant { + self.call_inner(method.into(), args) + } + + fn call_inner(&self, method: StringName, args: &[Variant]) -> Variant { + let args_sys: Vec<_> = args.iter().map(|v| v.var_sys_const()).collect(); + let mut error = sys::default_call_error(); + + #[allow(unused_mut)] + let mut result = Variant::nil(); + + unsafe { + interface_fn!(variant_call)( + self.var_sys(), + method.string_sys(), + args_sys.as_ptr(), + args_sys.len() as i64, + result.var_sys(), + ptr::addr_of_mut!(error), + ) + }; + + sys::panic_on_call_error(&error); + result + } + pub fn evaluate(&self, rhs: &Variant, op: VariantOperator) -> Option { let op_sys = op.sys(); let mut is_valid = false as u8; + #[allow(unused_mut)] let mut result = Variant::nil(); unsafe { interface_fn!(variant_evaluate)( diff --git a/godot-core/src/lib.rs b/godot-core/src/lib.rs index fd856ede4..d80b2ecbb 100644 --- a/godot-core/src/lib.rs +++ b/godot-core/src/lib.rs @@ -42,7 +42,7 @@ pub mod private { use crate::{log, sys}; - sys::plugin_registry!(__GODOT_PLUGIN_REGISTRY: ClassPlugin); + sys::plugin_registry!(pub __GODOT_PLUGIN_REGISTRY: ClassPlugin); pub(crate) fn iterate_plugins(mut visitor: impl FnMut(&ClassPlugin)) { sys::plugin_foreach!(__GODOT_PLUGIN_REGISTRY; visitor); diff --git a/godot-ffi/src/lib.rs b/godot-ffi/src/lib.rs index 51b951513..596158b95 100644 --- a/godot-ffi/src/lib.rs +++ b/godot-ffi/src/lib.rs @@ -140,6 +140,7 @@ unsafe fn unwrap_ref_unchecked_mut(opt: &mut Option) -> &mut T { } #[doc(hidden)] +#[inline] pub fn default_call_error() -> GDExtensionCallError { GDExtensionCallError { error: GDEXTENSION_CALL_OK, @@ -148,6 +149,32 @@ pub fn default_call_error() -> GDExtensionCallError { } } +#[doc(hidden)] +#[inline] +pub fn panic_on_call_error(err: &GDExtensionCallError) { + let actual = err.error; + + assert_eq!( + actual, + GDEXTENSION_CALL_OK, + "encountered Godot error code {}", + call_error_to_string(actual) + ); +} + +fn call_error_to_string(err: GDExtensionCallErrorType) -> &'static str { + match err { + GDEXTENSION_CALL_OK => "OK", + GDEXTENSION_CALL_ERROR_INVALID_METHOD => "ERROR_INVALID_METHOD", + GDEXTENSION_CALL_ERROR_INVALID_ARGUMENT => "ERROR_INVALID_ARGUMENT", + GDEXTENSION_CALL_ERROR_TOO_MANY_ARGUMENTS => "ERROR_TOO_MANY_ARGUMENTS", + GDEXTENSION_CALL_ERROR_TOO_FEW_ARGUMENTS => "ERROR_TOO_FEW_ARGUMENTS", + GDEXTENSION_CALL_ERROR_INSTANCE_IS_NULL => "ERROR_INSTANCE_IS_NULL", + GDEXTENSION_CALL_ERROR_METHOD_NOT_CONST => "ERROR_METHOD_NOT_CONST", + _ => "(unknown)", + } +} + #[macro_export] #[doc(hidden)] macro_rules! builtin_fn { diff --git a/godot-ffi/src/plugins.rs b/godot-ffi/src/plugins.rs index c8faabff7..e750b9088 100644 --- a/godot-ffi/src/plugins.rs +++ b/godot-ffi/src/plugins.rs @@ -15,12 +15,12 @@ #[doc(hidden)] #[macro_export] macro_rules! plugin_registry { - ($registry:ident: $Type:ty) => { + ($vis:vis $registry:ident: $Type:ty) => { $crate::paste::paste! { #[used] #[allow(non_upper_case_globals)] #[doc(hidden)] - pub static [< __godot_rust_plugin_ $registry >]: + $vis static [< __godot_rust_plugin_ $registry >]: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); } }; diff --git a/godot-macros/src/itest.rs b/godot-macros/src/itest.rs index 767f03f3a..0adfc7875 100644 --- a/godot-macros/src/itest.rs +++ b/godot-macros/src/itest.rs @@ -30,12 +30,11 @@ pub fn transform(input: TokenStream) -> Result { } let test_name = &func.name; - let init_msg = format!(" -- {test_name}"); - let error_msg = format!(" !! Test {test_name} failed"); + let test_name_str = func.name.to_string(); let body = &func.body; Ok(quote! { - #[doc(hidden)] + /*#[doc(hidden)] #[must_use] pub fn #test_name() -> bool { println!(#init_msg); @@ -47,6 +46,18 @@ pub fn transform(input: TokenStream) -> Result { ); success.is_some() + }*/ + + pub fn #test_name() { + #body } + + ::godot::sys::plugin_add!(__GODOT_ITEST in crate; crate::RustTestCase { + name: #test_name_str, + skipped: false, + file: std::file!(), + line: std::line!(), + function: #test_name, + }); }) } diff --git a/itest/godot/TestRunner.gd b/itest/godot/TestRunner.gd index bb529c3d7..98376e561 100644 --- a/itest/godot/TestRunner.gd +++ b/itest/godot/TestRunner.gd @@ -5,74 +5,43 @@ extends Node func _ready(): - var test_suites: Array = [ - IntegrationTests.new(), + var rust_runner = IntegrationTests.new() + + var gdscript_suites: Array = [ preload("res://ManualFfiTests.gd").new(), preload("res://gen/GenFfiTests.gd").new(), ] - var tests: Array[_Test] = [] - for suite in test_suites: + var gdscript_tests: Array = [] + for suite in gdscript_suites: for method in suite.get_method_list(): var method_name: String = method.name if method_name.begins_with("test_"): - tests.push_back(_Test.new(suite, method_name)) - - print() - print_rich(" [b][color=green]Running[/color][/b] test project %s" % [ - ProjectSettings.get_setting("application/config/name", ""), - ]) - print() - - var stats: TestStats = TestStats.new() - stats.start_stopwatch() - for test in tests: - printraw(" -- %s ... " % [test.test_name]) - var ok: bool = test.run() - print_rich("[color=green]ok[/color]" if ok else "[color=red]FAILED[/color]") - stats.add(ok) - stats.stop_stopwatch() - - print() - print_rich("test result: %s. %d passed; %d failed; finished in %.2fs" % [ - "[color=green]ok[/color]" if stats.all_passed() else "[color=red]FAILED[/color]", - stats.num_ok, - stats.num_failed, - stats.runtime_seconds(), - ]) - print() - - for suite in test_suites: - suite.free() - - var exit_code: int = 0 if stats.all_passed() else 1 + gdscript_tests.push_back(GDScriptTestCase.new(suite, method_name)) + + var success: bool = rust_runner.run_all_tests(gdscript_tests, gdscript_suites.size()) + + var exit_code: int = 0 if success else 1 get_tree().quit(exit_code) -class _Test: + +class GDScriptTestCase: var suite: Object var method_name: String - var test_name: String + var suite_name: String func _init(suite: Object, method_name: String): self.suite = suite self.method_name = method_name - self.test_name = "%s::%s" % [_suite_name(suite), method_name] - + self.suite_name = _suite_name(suite) + func run(): # This is a no-op if the suite doesn't have this property. suite.set("_assertion_failed", false) var result = suite.call(method_name) - var ok: bool = ( - (result == true || result == null) - && !suite.get("_assertion_failed") - ) + var ok: bool = (result == true || result == null) && !suite.get("_assertion_failed") return ok static func _suite_name(suite: Object) -> String: var script: GDScript = suite.get_script() - if script: - # Test suite written in GDScript. - return script.resource_path.get_file().get_basename() - else: - # Test suite written in Rust. - return suite.get_class() + return str(script.resource_path.get_file().get_basename(), ".gd") diff --git a/itest/godot/TestStats.gd b/itest/godot/TestStats.gd deleted file mode 100644 index c6804d27d..000000000 --- a/itest/godot/TestStats.gd +++ /dev/null @@ -1,33 +0,0 @@ -# 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/. - -class_name TestStats -extends RefCounted - -var num_run := 0 -var num_ok := 0 -var num_failed := 0 - -var _start_time_usec := 0 -var _runtime_usec := 0 - -func add(ok: bool): - num_run += 1 - if ok: - num_ok += 1 - else: - num_failed += 1 - -func all_passed() -> bool: - # Consider 0 tests run as a failure too, because it's probably a problem with the run itself. - return num_failed == 0 && num_run > 0 - -func start_stopwatch(): - _start_time_usec = Time.get_ticks_usec() - -func stop_stopwatch(): - _runtime_usec += Time.get_ticks_usec() - _start_time_usec - -func runtime_seconds() -> float: - return _runtime_usec * 1.0e-6 diff --git a/itest/godot/TestSuite.gd b/itest/godot/TestSuite.gd index 88f3a7c82..bde7b4b3c 100644 --- a/itest/godot/TestSuite.gd +++ b/itest/godot/TestSuite.gd @@ -3,27 +3,30 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. class_name TestSuite -extends Node +extends RefCounted var _assertion_failed: bool = false ## Asserts that `what` is `true`, but does not abort the test. Returns `what` so you can return ## early from the test function if the assertion failed. func assert_that(what: bool, message: String = "") -> bool: - if !what: - _assertion_failed = true - if message: - print("assertion failed: %s" % message) - else: - print("assertion failed") - return what + if what: + return true + + _assertion_failed = true + if message: + print("assertion failed: %s" % message) + else: + print("assertion failed") + return false func assert_eq(left, right, message: String = "") -> bool: - if left != right: - _assertion_failed = true - if message: - print("assertion failed: %s\n left: %s\n right: %s" % [message, left, right]) - else: - print("assertion failed: `(left == right)`\n left: %s\n right: %s" % [left, right]) - return false - return true + if left == right: + return true + + _assertion_failed = true + if message: + print("assertion failed: %s\n left: %s\n right: %s" % [message, left, right]) + else: + print("assertion failed: `(left == right)`\n left: %s\n right: %s" % [left, right]) + return false diff --git a/itest/rust/src/array_test.rs b/itest/rust/src/array_test.rs index 02a15e2e2..c54a689c8 100644 --- a/itest/rust/src/array_test.rs +++ b/itest/rust/src/array_test.rs @@ -7,46 +7,6 @@ use crate::{expect_panic, itest}; use godot::prelude::*; -pub fn run() -> bool { - let mut ok = true; - ok &= array_default(); - ok &= array_new(); - ok &= array_eq(); - ok &= typed_array_from_to_variant(); - ok &= untyped_array_from_to_variant(); - ok &= array_from_packed_array(); - ok &= array_from_iterator(); - ok &= array_from_slice(); - ok &= array_try_into_vec(); - ok &= array_iter_shared(); - ok &= array_hash(); - ok &= array_share(); - ok &= array_duplicate_shallow(); - ok &= array_duplicate_deep(); - ok &= array_slice_shallow(); - ok &= array_slice_deep(); - ok &= array_get(); - ok &= array_first_last(); - ok &= array_binary_search(); - ok &= array_find(); - ok &= array_rfind(); - ok &= array_min_max(); - ok &= array_pick_random(); - ok &= array_set(); - ok &= array_push_pop(); - ok &= array_insert(); - ok &= array_extend(); - ok &= array_reverse(); - ok &= array_sort(); - ok &= array_shuffle(); - ok &= array_mixed_values(); - ok &= untyped_array_pass_to_godot_func(); - ok &= untyped_array_return_from_godot_func(); - ok &= typed_array_pass_to_godot_func(); - ok &= typed_array_return_from_godot_func(); - ok -} - #[itest] fn array_default() { assert_eq!(Array::default().len(), 0); diff --git a/itest/rust/src/base_test.rs b/itest/rust/src/base_test.rs index b7e503c8f..c9fdf8f13 100644 --- a/itest/rust/src/base_test.rs +++ b/itest/rust/src/base_test.rs @@ -7,17 +7,6 @@ use crate::itest; use godot::prelude::*; -pub(crate) fn run() -> bool { - let mut ok = true; - ok &= base_instance_id(); - ok &= base_deref(); - ok &= base_display(); - ok &= base_debug(); - ok &= base_with_init(); - - ok -} - /* #[itest] fn base_test_is_weak() { diff --git a/itest/rust/src/basis_test.rs b/itest/rust/src/basis_test.rs index 7de930843..3a01c34d2 100644 --- a/itest/rust/src/basis_test.rs +++ b/itest/rust/src/basis_test.rs @@ -14,14 +14,6 @@ const TEST_BASIS: Basis = Basis::from_rows( Vector3::new(-0.160881, 0.152184, 0.97517), ); -pub(crate) fn run() -> bool { - let mut ok = true; - ok &= basis_multiply_same(); - ok &= basis_euler_angles_same(); - - ok -} - #[itest] fn basis_multiply_same() { let rust_res = TEST_BASIS * Basis::IDENTITY; diff --git a/itest/rust/src/builtin_test.rs b/itest/rust/src/builtin_test.rs index 7138bd5d6..87f1ba692 100644 --- a/itest/rust/src/builtin_test.rs +++ b/itest/rust/src/builtin_test.rs @@ -8,14 +8,6 @@ use crate::itest; use godot::builtin::inner::*; use godot::prelude::*; -pub(crate) fn run() -> bool { - let mut ok = true; - ok &= test_builtins_vector2(); - ok &= test_builtins_array(); - ok &= test_builtins_callable(); - ok -} - #[itest] fn test_builtins_vector2() { let vec = Vector2::new(3.0, -4.0); diff --git a/itest/rust/src/codegen_test.rs b/itest/rust/src/codegen_test.rs index 2fe505f72..f7f55eaf1 100644 --- a/itest/rust/src/codegen_test.rs +++ b/itest/rust/src/codegen_test.rs @@ -12,15 +12,6 @@ use godot::builtin::inner::{InnerColor, InnerString}; use godot::engine::{FileAccess, HttpRequest}; use godot::prelude::*; -pub fn run() -> bool { - let mut ok = true; - ok &= codegen_class_renamed(); - ok &= codegen_base_renamed(); - ok &= codegen_static_builtin_method(); - ok &= codegen_static_class_method(); - ok -} - #[itest] fn codegen_class_renamed() { // Known as `HTTPRequest` in Godot diff --git a/itest/rust/src/color_test.rs b/itest/rust/src/color_test.rs index ccbb12bad..34a0efec7 100644 --- a/itest/rust/src/color_test.rs +++ b/itest/rust/src/color_test.rs @@ -7,20 +7,6 @@ use crate::itest; use godot::builtin::{Color, ColorChannelOrder}; -pub fn run() -> bool { - let mut ok = true; - ok &= color_from_rgba8(); - ok &= color_from_u32(); - ok &= color_from_u64(); - ok &= color_from_html(); - ok &= color_from_string(); - ok &= color_get_set_u8(); - ok &= color_blend(); - ok &= color_to_u32(); - ok &= color_to_u64(); - ok -} - #[itest] fn color_from_rgba8() { assert_eq!( diff --git a/itest/rust/src/dictionary_test.rs b/itest/rust/src/dictionary_test.rs index e8c68d281..d1b20c146 100644 --- a/itest/rust/src/dictionary_test.rs +++ b/itest/rust/src/dictionary_test.rs @@ -7,43 +7,8 @@ use std::collections::{HashMap, HashSet}; use crate::{expect_panic, itest}; -use godot::{ - builtin::{dict, varray, Dictionary, FromVariant, ToVariant}, - prelude::{Share, Variant}, -}; - -pub fn run() -> bool { - let mut ok = true; - ok &= dictionary_default(); - ok &= dictionary_new(); - ok &= dictionary_from_iterator(); - ok &= dictionary_from(); - ok &= dictionary_macro(); - ok &= dictionary_clone(); - ok &= dictionary_duplicate_deep(); - ok &= dictionary_hash(); - ok &= dictionary_get(); - ok &= dictionary_insert(); - ok &= dictionary_insert_multiple(); - ok &= dictionary_insert_long(); - ok &= dictionary_extend(); - ok &= dictionary_remove(); - ok &= dictionary_clear(); - ok &= dictionary_find_key(); - ok &= dictionary_contains_keys(); - ok &= dictionary_keys_values(); - ok &= dictionary_equal(); - ok &= dictionary_iter(); - ok &= dictionary_iter_equals_big(); - ok &= dictionary_iter_insert(); - ok &= dictionary_iter_insert_after_completion(); - ok &= dictionary_iter_big(); - ok &= dictionary_iter_simultaneous(); - ok &= dictionary_iter_panics(); - ok &= dictionary_iter_clear(); - ok &= dictionary_iter_erase(); - ok -} +use godot::builtin::{dict, varray, Dictionary, FromVariant, ToVariant, Variant}; +use godot::obj::Share; #[itest] fn dictionary_default() { diff --git a/itest/rust/src/enum_test.rs b/itest/rust/src/enum_test.rs index faa8d908e..4263567e0 100644 --- a/itest/rust/src/enum_test.rs +++ b/itest/rust/src/enum_test.rs @@ -9,14 +9,6 @@ use godot::engine::input::CursorShape; use godot::engine::time; use std::collections::HashSet; -pub fn run() -> bool { - let mut ok = true; - ok &= enum_ords_correct(); - ok &= enum_equality(); - ok &= enum_hash(); - ok -} - #[itest] fn enum_ords_correct() { use godot::obj::EngineEnum; diff --git a/itest/rust/src/export_test.rs b/itest/rust/src/export_test.rs index 1357b1356..868c59ae8 100644 --- a/itest/rust/src/export_test.rs +++ b/itest/rust/src/export_test.rs @@ -6,11 +6,7 @@ use godot::prelude::*; -pub(crate) fn run() -> bool { - // No tests currently, tests using HasProperty are in Godot scripts. - - true -} +// No tests currently, tests using HasProperty are in Godot scripts. #[derive(GodotClass)] #[class(base=Node)] diff --git a/itest/rust/src/gdscript_ffi_test.rs b/itest/rust/src/gdscript_ffi_test.rs index caf5bb3da..c75f7efae 100644 --- a/itest/rust/src/gdscript_ffi_test.rs +++ b/itest/rust/src/gdscript_ffi_test.rs @@ -9,7 +9,3 @@ #[rustfmt::skip] #[path = "gen/gen_ffi.rs"] mod gen_ffi; - -pub(crate) fn run() -> bool { - true -} diff --git a/itest/rust/src/lib.rs b/itest/rust/src/lib.rs index 015eaf855..099d5e488 100644 --- a/itest/rust/src/lib.rs +++ b/itest/rust/src/lib.rs @@ -4,10 +4,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use godot::bind::{godot_api, GodotClass}; use godot::init::{gdextension, ExtensionLibrary}; -use godot::test::itest; -use std::panic::UnwindSafe; +use godot::sys; mod array_test; mod base_test; @@ -29,59 +27,61 @@ mod utilities_test; mod variant_test; mod virtual_methods_test; -fn run_tests() -> bool { - let mut ok = true; - ok &= array_test::run(); - ok &= base_test::run(); - ok &= basis_test::run(); - ok &= builtin_test::run(); - ok &= codegen_test::run(); - ok &= color_test::run(); - ok &= dictionary_test::run(); - ok &= enum_test::run(); - ok &= export_test::run(); - ok &= gdscript_ffi_test::run(); - ok &= node_test::run(); - ok &= object_test::run(); - ok &= packed_array_test::run(); - ok &= quaternion_test::run(); - ok &= singleton_test::run(); - ok &= string_test::run(); - ok &= utilities_test::run(); - ok &= variant_test::run(); - ok &= virtual_methods_test::run(); - ok -} - -// fn register_classes() { -// object_test::register(); -// gdscript_ffi_test::register(); -// virtual_methods_test::register(); -// } +mod runner; // ---------------------------------------------------------------------------------------------------------------------------------------------- -// Implementation - -#[derive(GodotClass, Debug)] -#[class(base=Node, init)] -struct IntegrationTests {} - -#[godot_api] -impl IntegrationTests { - #[func] - fn test_all(&mut self) -> bool { - println!("Run Godot integration tests..."); - run_tests() - } -} +// API for test cases -#[gdextension(entry_point=itest_init)] -unsafe impl ExtensionLibrary for IntegrationTests {} +use godot::test::itest; + +pub(crate) fn expect_panic(context: &str, code: impl FnOnce() + std::panic::UnwindSafe) { + use std::panic; + + // Exchange panic hook, to disable printing during expected panics + let prev_hook = panic::take_hook(); + panic::set_hook(Box::new(|_panic_info| {})); + + // Run code that should panic, restore hook + let panic = panic::catch_unwind(code); + panic::set_hook(prev_hook); -pub(crate) fn expect_panic(context: &str, code: impl FnOnce() + UnwindSafe) { - let panic = std::panic::catch_unwind(code); assert!( panic.is_err(), "code should have panicked but did not: {context}", ); } + +// ---------------------------------------------------------------------------------------------------------------------------------------------- +// Entry point + #[itest] test registration + +#[gdextension(entry_point=itest_init)] +unsafe impl ExtensionLibrary for runner::IntegrationTests {} + +// Registers all the `#[itest]` tests. +sys::plugin_registry!(__GODOT_ITEST: RustTestCase); + +/// Finds all `#[itest]` tests. +fn collect_rust_tests() -> (Vec, usize) { + let mut all_files = std::collections::HashSet::new(); + let mut tests: Vec = vec![]; + + sys::plugin_foreach!(__GODOT_ITEST; |test: &RustTestCase| { + all_files.insert(test.file); + tests.push(*test); + }); + + // Sort alphabetically for deterministic run order + tests.sort_by_key(|test| test.file); + + (tests, all_files.len()) +} + +#[derive(Copy, Clone)] +struct RustTestCase { + name: &'static str, + file: &'static str, + skipped: bool, + #[allow(dead_code)] + line: u32, + function: fn(), +} diff --git a/itest/rust/src/node_test.rs b/itest/rust/src/node_test.rs index 5dd6b19ef..a5aee952c 100644 --- a/itest/rust/src/node_test.rs +++ b/itest/rust/src/node_test.rs @@ -7,24 +7,8 @@ use crate::itest; use godot::builtin::NodePath; use godot::engine::{node, Node, Node3D, NodeExt}; -use godot::log::godot_print; use godot::obj::Share; -pub fn run() -> bool { - let mut ok = true; - ok &= node_print(); - ok &= node_get_node(); - ok &= node_get_node_fail(); - //ok &= node_scene_tree(); - ok -} - -// TODO move to other test -#[itest] -fn node_print() { - godot_print!("Test print, bool={} and int={}", true, 32); -} - #[itest] fn node_get_node() { let mut child = Node3D::new_alloc(); diff --git a/itest/rust/src/object_test.rs b/itest/rust/src/object_test.rs index 95364f0fa..19d9362f5 100644 --- a/itest/rust/src/object_test.rs +++ b/itest/rust/src/object_test.rs @@ -15,51 +15,6 @@ use godot::sys::GodotFfi; use std::cell::RefCell; use std::rc::Rc; -// pub(crate) fn register() { -// godot::register_class::(); -// godot::register_class::(); -// } - -pub fn run() -> bool { - let mut ok = true; - ok &= object_construct_default(); - ok &= object_construct_value(); - ok &= object_user_roundtrip_return(); - ok &= object_user_roundtrip_write(); - ok &= object_engine_roundtrip(); - ok &= object_display(); - ok &= object_debug(); - ok &= object_instance_id(); - ok &= object_instance_id_when_freed(); - ok &= object_from_invalid_instance_id(); - ok &= object_from_instance_id_inherits_type(); - ok &= object_from_instance_id_unrelated_type(); - ok &= object_user_convert_variant(); - ok &= object_engine_convert_variant(); - ok &= object_user_convert_variant_refcount(); - ok &= object_engine_convert_variant_refcount(); - ok &= object_engine_returned_refcount(); - ok &= object_engine_up_deref(); - ok &= object_engine_up_deref_mut(); - ok &= object_engine_upcast(); - ok &= object_engine_upcast_reflexive(); - ok &= object_engine_downcast(); - ok &= object_engine_downcast_reflexive(); - ok &= object_engine_bad_downcast(); - ok &= object_engine_accept_polymorphic(); - ok &= object_user_upcast(); - ok &= object_user_downcast(); - ok &= object_user_bad_downcast(); - ok &= object_user_accept_polymorphic(); - ok &= object_engine_manual_free(); - ok &= object_engine_manual_double_free(); - ok &= object_engine_refcounted_free(); - ok &= object_user_share_drop(); - ok &= object_call_no_args(); - ok &= object_call_with_args(); - ok -} - // TODO: // * make sure that ptrcalls are used when possible (ie. when type info available; maybe GDScript integration test) // * Deref impl for user-defined types diff --git a/itest/rust/src/packed_array_test.rs b/itest/rust/src/packed_array_test.rs index c7233fead..4e8855928 100644 --- a/itest/rust/src/packed_array_test.rs +++ b/itest/rust/src/packed_array_test.rs @@ -7,30 +7,6 @@ use crate::{expect_panic, itest}; use godot::builtin::{PackedByteArray, PackedFloat32Array}; -pub fn run() -> bool { - let mut ok = true; - ok &= packed_array_default(); - ok &= packed_array_new(); - ok &= packed_array_from_iterator(); - ok &= packed_array_from(); - ok &= packed_array_to_vec(); - // ok &= packed_array_into_iterator(); - ok &= packed_array_eq(); - ok &= packed_array_clone(); - ok &= packed_array_slice(); - ok &= packed_array_get(); - ok &= packed_array_binary_search(); - ok &= packed_array_find(); - ok &= packed_array_rfind(); - ok &= packed_array_set(); - ok &= packed_array_push(); - ok &= packed_array_insert(); - ok &= packed_array_extend(); - ok &= packed_array_reverse(); - ok &= packed_array_sort(); - ok -} - #[itest] fn packed_array_default() { assert_eq!(PackedByteArray::default().len(), 0); diff --git a/itest/rust/src/quaternion_test.rs b/itest/rust/src/quaternion_test.rs index 79ddc57c3..03dac4804 100644 --- a/itest/rust/src/quaternion_test.rs +++ b/itest/rust/src/quaternion_test.rs @@ -7,13 +7,6 @@ use crate::itest; use godot::builtin::Quaternion; -pub fn run() -> bool { - let mut ok = true; - ok &= quaternion_default(); - ok &= quaternion_from_xyzw(); - ok -} - #[itest] fn quaternion_default() { let quat = Quaternion::default(); diff --git a/itest/rust/src/runner.rs b/itest/rust/src/runner.rs new file mode 100644 index 000000000..e697eccb3 --- /dev/null +++ b/itest/rust/src/runner.rs @@ -0,0 +1,199 @@ +/* + * 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::bind::{godot_api, GodotClass}; +use godot::builtin::{Array, ToVariant, Variant}; + +use crate::RustTestCase; +use std::time::{Duration, Instant}; + +#[derive(GodotClass, Debug)] +#[class(init)] +pub(crate) struct IntegrationTests { + total: i64, + passed: i64, + skipped: i64, +} + +#[godot_api] +impl IntegrationTests { + #[allow(clippy::uninlined_format_args)] + #[func] + fn run_all_tests(&mut self, gdscript_tests: Array, gdscript_file_count: i64) -> bool { + println!( + "{}Run{} Godot integration tests...", + FMT_GREEN_BOLD, FMT_END + ); + + let (rust_tests, rust_file_count) = super::collect_rust_tests(); + println!( + " Rust: found {} tests in {} files.", + rust_tests.len(), + rust_file_count + ); + println!( + " GDScript: found {} tests in {} files.", + gdscript_tests.len(), + gdscript_file_count + ); + + let clock = Instant::now(); + self.run_rust_tests(rust_tests); + let rust_time = clock.elapsed(); + self.run_gdscript_tests(gdscript_tests); + let gdscript_time = clock.elapsed() - rust_time; + + self.conclude(rust_time, gdscript_time) + } + + fn run_rust_tests(&mut self, tests: Vec) { + let mut last_file = None; + for test in tests { + let outcome = run_rust_test(&test); + + self.update_stats(&outcome); + print_test(test.file.to_string(), test.name, outcome, &mut last_file); + } + } + + fn run_gdscript_tests(&mut self, tests: Array) { + let mut last_file = None; + for test in tests.iter_shared() { + let result = test.call("run", &[]); + let success = result.try_to::().unwrap_or_else(|_| { + panic!("GDScript test case {test} returned non-bool: {result}") + }); + + let test_file = get_property(&test, "suite_name"); + let test_case = get_property(&test, "method_name"); + let outcome = TestOutcome::from_bool(success); + + self.update_stats(&outcome); + print_test(test_file, &test_case, outcome, &mut last_file); + } + } + + fn conclude(&self, rust_time: Duration, gdscript_time: Duration) -> bool { + let Self { + total, + passed, + skipped, + .. + } = *self; + + // Consider 0 tests run as a failure too, because it's probably a problem with the run itself. + let failed = total - passed - skipped; + let all_passed = failed == 0 && total != 0; + + let outcome = TestOutcome::from_bool(all_passed); + + let rust_time = rust_time.as_secs_f32(); + let gdscript_time = gdscript_time.as_secs_f32(); + let total_time = rust_time + gdscript_time; + + println!("\nTest result: {outcome}. {passed} passed; {failed} failed."); + println!(" Time: {total_time:.2}s. (Rust {rust_time:.2}s, GDScript {gdscript_time:.2}s)"); + all_passed + } + + fn update_stats(&mut self, outcome: &TestOutcome) { + self.total += 1; + match outcome { + TestOutcome::Passed => self.passed += 1, + TestOutcome::Failed => {} + TestOutcome::Skipped => self.skipped += 1, + } + } +} + +// For more colors, see https://stackoverflow.com/a/54062826 +// To experiment with colors, add `rand` dependency and add following code above. +// use rand::seq::SliceRandom; +// let outcome = [TestOutcome::Passed, TestOutcome::Failed, TestOutcome::Skipped]; +// let outcome = outcome.choose(&mut rand::thread_rng()).unwrap(); +const FMT_GREEN_BOLD: &str = "\x1b[32;1;1m"; +const FMT_GREEN: &str = "\x1b[32m"; +const FMT_YELLOW: &str = "\x1b[33m"; +const FMT_RED: &str = "\x1b[31m"; +const FMT_END: &str = "\x1b[0m"; + +fn run_rust_test(test: &RustTestCase) -> TestOutcome { + if test.skipped { + return TestOutcome::Skipped; + } + + // Explicit type to prevent tests from returning a value + let success: Option<()> = + godot::private::handle_panic(|| format!(" !! Test {} failed", test.name), test.function); + + TestOutcome::from_bool(success.is_some()) +} + +/// Prints a test name and its outcome. +/// +/// Note that this is run after a test run, so stdout/stderr output during the test will be printed before. +/// It would be possible to print the test name before and the outcome after, but that would split or duplicate the line. +fn print_test( + test_file: String, + test_case: &str, + outcome: TestOutcome, + last_file: &mut Option, +) { + // Check if we need to open a new category for a file + let print_file = last_file + .as_ref() + .map_or(true, |last_file| last_file != &test_file); + + if print_file { + let file_subtitle = if let Some(sep_pos) = test_file.rfind(&['/', '\\']) { + &test_file[sep_pos + 1..] + } else { + test_file.as_str() + }; + + println!("\n {file_subtitle}:"); + } + + println!(" -- {test_case} ... {outcome}"); + + // State update for file-category-print + *last_file = Some(test_file); +} + +fn get_property(test: &Variant, property: &str) -> String { + test.call("get", &[property.to_variant()]).to::() +} + +#[must_use] +enum TestOutcome { + Passed, + Failed, + Skipped, +} + +impl TestOutcome { + fn from_bool(success: bool) -> Self { + if success { + Self::Passed + } else { + Self::Failed + } + } +} + +impl std::fmt::Display for TestOutcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Do not use print_rich() from Godot, because it's very slow and significantly delays test execution. + let end = FMT_END; + let (col, outcome) = match self { + TestOutcome::Passed => (FMT_GREEN, "ok"), + TestOutcome::Failed => (FMT_RED, "FAILED"), + TestOutcome::Skipped => (FMT_YELLOW, "ignored"), + }; + + write!(f, "{col}{outcome}{end}") + } +} diff --git a/itest/rust/src/singleton_test.rs b/itest/rust/src/singleton_test.rs index 1a6910686..1071b7e97 100644 --- a/itest/rust/src/singleton_test.rs +++ b/itest/rust/src/singleton_test.rs @@ -9,14 +9,6 @@ use godot::builtin::GodotString; use godot::engine::{Input, Os}; use godot::obj::Gd; -pub fn run() -> bool { - let mut ok = true; - ok &= singleton_is_unique(); - ok &= singleton_from_instance_id(); - ok &= singleton_is_operational(); - ok -} - #[itest] fn singleton_is_unique() { let a: Gd = Input::singleton(); diff --git a/itest/rust/src/string_test.rs b/itest/rust/src/string_test.rs index 8c2a63d66..aa9aea2c1 100644 --- a/itest/rust/src/string_test.rs +++ b/itest/rust/src/string_test.rs @@ -9,21 +9,6 @@ use godot::builtin::{GodotString, StringName}; // TODO use tests from godot-rust/gdnative -pub fn run() -> bool { - let mut ok = true; - ok &= string_default(); - ok &= string_conversion(); - ok &= string_equality(); - ok &= string_ordering(); - ok &= string_clone(); - ok &= string_name_conversion(); - ok &= string_name_default_construct(); - ok &= string_name_eq_hash(); - ok &= string_name_ord(); - ok &= string_name_clone(); - ok -} - #[itest] fn string_default() { let string = GodotString::new(); diff --git a/itest/rust/src/transform2d_test.rs b/itest/rust/src/transform2d_test.rs index 3334e73ea..7191dd6c0 100644 --- a/itest/rust/src/transform2d_test.rs +++ b/itest/rust/src/transform2d_test.rs @@ -14,12 +14,6 @@ const TEST_TRANSFORM: Transform2D = Transform2D::from_cols( Vector2::new(5.0, 6.0), ); -pub(crate) fn run() -> bool { - let mut ok = true; - ok &= transform2d_equiv(); - ok -} - #[itest] fn transform2d_equiv() { let inner = InnerTransform2D::from_outer(&TEST_TRANSFORM); diff --git a/itest/rust/src/transform3d_test.rs b/itest/rust/src/transform3d_test.rs index b7a54b0b4..4dd07ee9d 100644 --- a/itest/rust/src/transform3d_test.rs +++ b/itest/rust/src/transform3d_test.rs @@ -17,12 +17,6 @@ const TEST_TRANSFORM: Transform3D = Transform3D::new( Vector3::new(10.0, 11.0, 12.0), ); -pub(crate) fn run() -> bool { - let mut ok = true; - ok &= transform3d_equiv(); - ok -} - #[itest] fn transform3d_equiv() { let inner = InnerTransform3D::from_outer(&TEST_TRANSFORM); diff --git a/itest/rust/src/utilities_test.rs b/itest/rust/src/utilities_test.rs index 7135bf8eb..b4b2360f6 100644 --- a/itest/rust/src/utilities_test.rs +++ b/itest/rust/src/utilities_test.rs @@ -9,15 +9,6 @@ use crate::itest; use godot::builtin::Variant; use godot::engine::utilities::*; -pub fn run() -> bool { - let mut ok = true; - ok &= utilities_abs(); - ok &= utilities_sign(); - ok &= utilities_wrap(); - ok &= utilities_max(); - ok -} - #[itest] fn utilities_abs() { let input = Variant::from(-7); diff --git a/itest/rust/src/variant_test.rs b/itest/rust/src/variant_test.rs index 1211baeb7..2a92472c7 100644 --- a/itest/rust/src/variant_test.rs +++ b/itest/rust/src/variant_test.rs @@ -4,11 +4,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use crate::itest; +use crate::{expect_panic, itest}; use godot::builtin::{ FromVariant, GodotString, NodePath, StringName, ToVariant, Variant, Vector2, Vector3, }; -use godot::engine::Node3D; +use godot::engine::Node2D; use godot::obj::InstanceId; use godot::prelude::{Array, Basis, Dictionary, VariantConversionError}; use godot::sys::{GodotFfi, VariantOperator, VariantType}; @@ -21,24 +21,6 @@ const TEST_BASIS: Basis = Basis::from_rows( Vector3::new(7.0, 8.0, 9.0), ); -pub fn run() -> bool { - let mut ok = true; - ok &= variant_nil(); - ok &= variant_conversions(); - ok &= variant_forbidden_conversions(); - ok &= variant_display(); - ok &= variant_get_type(); - ok &= variant_equal(); - ok &= variant_evaluate(); - ok &= variant_evaluate_total_order(); - ok &= variant_sys_conversion(); - ok &= variant_sys_conversion2(); - ok &= variant_null_object_is_nil(); - ok &= variant_conversion_fails(); - ok &= variant_type_correct(); - ok -} - #[itest] fn variant_nil() { let variant = Variant::nil(); @@ -126,6 +108,53 @@ fn variant_equal() { equal(gstr("String"), 33, false); } +#[itest] +fn variant_call() { + use godot::obj::Share; + let node2d = Node2D::new_alloc(); + let variant = Variant::from(node2d.share()); + + // Object + let position = Vector2::new(4.0, 5.0); + let result = variant.call("set_position", &[position.to_variant()]); + assert!(result.is_nil()); + + let result = variant.call("get_position", &[]); + assert_eq!(result.try_to::(), Ok(position)); + + let result = variant.call("to_string", &[]); + assert_eq!(result.get_type(), VariantType::String); + + // Array + let array = godot::builtin::varray![1, "hello", false]; + let result = array.to_variant().call("size", &[]); + assert_eq!(result, 3.to_variant()); + + // String + let string = GodotString::from("move_local_x"); + let result = string.to_variant().call("capitalize", &[]); + assert_eq!(result, "Move Local X".to_variant()); + + // Vector2 + let vector = Vector2::new(5.0, 3.0); + let vector_rhs = Vector2::new(1.0, -1.0); + let result = vector.to_variant().call("dot", &[vector_rhs.to_variant()]); + assert_eq!(result, 2.0.to_variant()); + + // Error cases + expect_panic("Variant::call on non-existent method", || { + variant.call("gut_position", &[]); + }); + expect_panic("Variant::call with bad signature", || { + variant.call("set_position", &[]); + }); + expect_panic("Variant::call with non-object variant (int)", || { + Variant::from(77).call("to_string", &[]); + }); + + node2d.free(); +} + #[rustfmt::skip] #[itest] fn variant_evaluate() { @@ -220,7 +249,7 @@ fn variant_sys_conversion() { fn variant_null_object_is_nil() { use godot::sys; - let mut node = Node3D::new_alloc(); + let mut node = Node2D::new_alloc(); let node_path = NodePath::from("res://NonExisting.tscn"); // Simulates an object that is returned but null diff --git a/itest/rust/src/virtual_methods_test.rs b/itest/rust/src/virtual_methods_test.rs index c51676ab3..9a9d8dd1a 100644 --- a/itest/rust/src/virtual_methods_test.rs +++ b/itest/rust/src/virtual_methods_test.rs @@ -39,16 +39,6 @@ impl GodotExt for VirtualMethodTest { } } -pub(crate) fn run() -> bool { - let mut ok = true; - ok &= test_to_string(); - ok -} - -// pub(crate) fn register() { -// godot::register_class::(); -// } - // ---------------------------------------------------------------------------------------------------------------------------------------------- #[itest]