diff options
Diffstat (limited to 'addons/gdUnit4/src/dotnet')
| -rw-r--r-- | addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs | 235 | ||||
| -rw-r--r-- | addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd | 114 | ||||
| -rw-r--r-- | addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid | 1 |
3 files changed, 350 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs new file mode 100644 index 0000000..096e83c --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs @@ -0,0 +1,235 @@ +// Copyright (c) 2025 Mike Schulze +// MIT License - See LICENSE file in the repository root for full license text +#pragma warning disable IDE1006 +namespace gdUnit4.addons.gdUnit4.src.dotnet; +#pragma warning restore IDE1006 + +#if GDUNIT4NET_API_V5 +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GdUnit4; +using GdUnit4.Api; + +using Godot; +using Godot.Collections; + +/// <summary> +/// The GdUnit4 GDScript - C# API wrapper. +/// </summary> +public partial class GdUnit4CSharpApi : RefCounted +{ + /// <summary> + /// The signal to be emitted when the execution is completed. + /// </summary> + [Signal] +#pragma warning disable CA1711 + public delegate void ExecutionCompletedEventHandler(); +#pragma warning restore CA1711 + +#pragma warning disable CA2213, SA1201 + private CancellationTokenSource? executionCts; +#pragma warning restore CA2213, SA1201 + + /// <summary> + /// Indicates if the API loaded. + /// </summary> + /// <returns>Returns true if the API already loaded.</returns> + public static bool IsApiLoaded() + => true; + + /// <summary> + /// Runs test discovery on the given script. + /// </summary> + /// <param name="sourceScript">The script to be scanned.</param> + /// <returns>The list of tests discovered as dictionary.</returns> + public static Array<Dictionary> DiscoverTests(CSharpScript sourceScript) + { + try + { + // Get the list of test case descriptors from the API + var testCaseDescriptors = GdUnit4NetApiGodotBridge.DiscoverTestsFromScript(sourceScript); + + // Convert each TestCaseDescriptor to a Dictionary + return testCaseDescriptors + .Select(descriptor => new Dictionary + { + ["guid"] = descriptor.Id.ToString(), + ["managed_type"] = descriptor.ManagedType, + ["test_name"] = descriptor.ManagedMethod, + ["source_file"] = sourceScript.ResourcePath, + ["line_number"] = descriptor.LineNumber, + ["attribute_index"] = descriptor.AttributeIndex, + ["require_godot_runtime"] = descriptor.RequireRunningGodotEngine, + ["code_file_path"] = descriptor.CodeFilePath ?? string.Empty, + ["simple_name"] = descriptor.SimpleName, + ["fully_qualified_name"] = descriptor.FullyQualifiedName, + ["assembly_location"] = descriptor.AssemblyPath + }) + .Aggregate( + new Array<Dictionary>(), + (array, dict) => + { + array.Add(dict); + return array; + }); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error discovering tests: {e.Message}\n{e.StackTrace}"); +#pragma warning disable IDE0028 // Do not catch general exception types + return new Array<Dictionary>(); +#pragma warning restore IDE0028 // Do not catch general exception types + } + } + + /// <summary> + /// Creates a test suite based on the specified source path and line number. + /// </summary> + /// <param name="sourcePath">The path to the source file from which to create the test suite.</param> + /// <param name="lineNumber">The line number in the source file where the method to test is defined.</param> + /// <param name="testSuitePath">The path where the test suite should be created.</param> + /// <returns>A dictionary containing information about the created test suite.</returns> + public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath) + => GdUnit4NetApiGodotBridge.CreateTestSuite(sourcePath, lineNumber, testSuitePath); + + /// <summary> + /// Gets the version of the GdUnit4 assembly. + /// </summary> + /// <returns>The version string of the GdUnit4 assembly.</returns> + public static string Version() + => GdUnit4NetApiGodotBridge.Version(); + + /// <inheritdoc /> + public override void _Notification(int what) + { + if (what != NotificationPredelete) + return; + executionCts?.Dispose(); + executionCts = null; + } + + /// <summary> + /// Executes the tests and using the listener for reporting the results. + /// </summary> + /// <param name="tests">A list of tests to be executed.</param> + /// <param name="listener">The listener to report the results.</param> + public void ExecuteAsync(Array<Dictionary> tests, Callable listener) + { + try + { + // Cancel any ongoing execution + executionCts?.Cancel(); + executionCts?.Dispose(); + + // Create new cancellation token source + executionCts = new CancellationTokenSource(); + + Debug.Assert(tests != null, nameof(tests) + " != null"); + var testSuiteNodes = new List<TestSuiteNode> { BuildTestSuiteNodeFrom(tests) }; + GdUnit4NetApiGodotBridge.ExecuteAsync(testSuiteNodes, listener, executionCts.Token) + .GetAwaiter() + .OnCompleted(() => EmitSignal(SignalName.ExecutionCompleted)); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error executing tests: {e.Message}\n{e.StackTrace}"); + Task.Run(() => { }).GetAwaiter().OnCompleted(() => EmitSignal(SignalName.ExecutionCompleted)); + } + } + + /// <summary> + /// Will cancel the current test execution. + /// </summary> + public void CancelExecution() + { + try + { + executionCts?.Cancel(); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error cancelling execution: {e.Message}"); + } + } + + // Convert a set of Tests stored as Dictionaries to TestSuiteNode + // all tests are assigned to a single test suit + internal static TestSuiteNode BuildTestSuiteNodeFrom(Array<Dictionary> tests) + { + if (tests.Count == 0) + throw new InvalidOperationException("Cant build 'TestSuiteNode' from an empty test set."); + + // Create a suite ID + var suiteId = Guid.NewGuid(); + var firstTest = tests[0]; + var managedType = firstTest["managed_type"].AsString(); + var assemblyLocation = firstTest["assembly_location"].AsString(); + var sourceFile = firstTest["source_file"].AsString(); + + // Create TestCaseNodes for each test in the suite + var testCaseNodes = tests + .Select(test => new TestCaseNode + { + Id = Guid.Parse(test["guid"].AsString()), + ParentId = suiteId, + ManagedMethod = test["test_name"].AsString(), + LineNumber = test["line_number"].AsInt32(), + AttributeIndex = test["attribute_index"].AsInt32(), + RequireRunningGodotEngine = test["require_godot_runtime"].AsBool() + }) + .ToList(); + + return new TestSuiteNode + { + Id = suiteId, + ParentId = Guid.Empty, + ManagedType = managedType, + AssemblyPath = assemblyLocation, + SourceFile = sourceFile, + Tests = testCaseNodes + }; + } +} +#else +using Godot; +using Godot.Collections; + +public partial class GdUnit4CSharpApi : RefCounted +{ + [Signal] + public delegate void ExecutionCompletedEventHandler(); + + public static bool IsApiLoaded() + { + GD.PushWarning("No `gdunit4.api` dependency found, check your project dependencies."); + return false; + } + + + public static string Version() + => "Unknown"; + + public static Array<Dictionary> DiscoverTests(CSharpScript sourceScript) => new(); + + public void ExecuteAsync(Array<Dictionary> tests, Callable listener) + { + } + + public static bool IsTestSuite(CSharpScript script) + => false; + + public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath) + => new(); +} +#endif diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd new file mode 100644 index 0000000..3d8ba25 --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd @@ -0,0 +1,114 @@ +## GdUnit4CSharpApiLoader +## +## A bridge class that handles communication between GDScript and C# for the GdUnit4 testing framework. +## This loader acts as a compatibility layer to safely access the .NET API and ensure that calls +## only proceed when the .NET environment is properly configured and available. +## [br] +## The class handles: +## - Verification of .NET runtime availability +## - Loading the C# wrapper script +## - Checking for the GdUnit4Api assembly +## - Providing proxy methods to access GdUnit4 functionality in C# +@static_unload +class_name GdUnit4CSharpApiLoader +extends RefCounted + +## Cached reference to the loaded C# wrapper script +static var _gdUnit4NetWrapper: Script + +## Cached instance of the API (singleton pattern) +static var _api_instance: RefCounted + + +class TestEventListener extends RefCounted: + + func publish_event(event: Dictionary) -> void: + var test_event := GdUnitEvent.new().deserialize(event) + GdUnitSignals.instance().gdunit_event.emit(test_event) + +static var _test_event_listener := TestEventListener.new() + + +## Returns an instance of the GdUnit4CSharpApi wrapper.[br] +## @return Script: The loaded C# wrapper or null if .NET is not supported +static func instance() -> Script: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return null + + return _gdUnit4NetWrapper + + +## Returns or creates a single instance of the API [br] +## This improves performance by reusing the same object +static func api_instance() -> RefCounted: + if _api_instance == null and is_api_loaded(): + @warning_ignore("unsafe_method_access") + _api_instance = instance().new() + return _api_instance + + +static func is_engine_version_supported(engine_version: int = Engine.get_version_info().hex) -> bool: + return engine_version >= 0x40200 + + +## Checks if the .NET environment is properly configured and available.[br] +## @return bool: True if .NET is fully supported and the assembly is found +static func is_api_loaded() -> bool: + # If the wrapper is already loaded we don't need to check again + if _gdUnit4NetWrapper != null: + return true + + # First we check if this is a Godot .NET runtime instance + if not ClassDB.class_exists("CSharpScript") or not is_engine_version_supported(): + return false + # Second we check the C# project file exists + var assembly_name: String = ProjectSettings.get_setting("dotnet/project/assembly_name") + if assembly_name.is_empty() or not FileAccess.file_exists("res://%s.csproj" % assembly_name): + return false + + # Finally load the wrapper and check if the GdUnit4 assembly can be found + _gdUnit4NetWrapper = load("res://addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs") + @warning_ignore("unsafe_method_access") + return _gdUnit4NetWrapper.call("IsApiLoaded") + + +## Returns the version of the GdUnit4 .NET assembly.[br] +## @return String: The version string or "unknown" if .NET is not supported +static func version() -> String: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return "unknown" + @warning_ignore("unsafe_method_access") + return instance().Version() + + +static func discover_tests(source_script: Script) -> Array[GdUnitTestCase]: + var tests: Array = _gdUnit4NetWrapper.call("DiscoverTests", source_script) + + return Array(tests.map(GdUnitTestCase.from_dict), TYPE_OBJECT, "RefCounted", GdUnitTestCase) + + +static func execute(tests: Array[GdUnitTestCase]) -> void: + var net_api := api_instance() + if net_api == null: + push_warning("Execute C# tests not supported!") + return + var tests_as_dict: Array[Dictionary] = Array(tests.map(GdUnitTestCase.to_dict), TYPE_DICTIONARY, "", null) + + net_api.call("ExecuteAsync", tests_as_dict, _test_event_listener.publish_event) + @warning_ignore("unsafe_property_access") + await net_api.ExecutionCompleted + + +static func create_test_suite(source_path: String, line_number: int, test_suite_path: String) -> GdUnitResult: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return GdUnitResult.error("Can't create test suite. No .NET support found.") + @warning_ignore("unsafe_method_access") + var result: Dictionary = instance().CreateTestSuite(source_path, line_number, test_suite_path) + if result.has("error"): + return GdUnitResult.error(str(result.get("error"))) + return GdUnitResult.success(result) + + +static func is_csharp_file(resource_path: String) -> bool: + var ext := resource_path.get_extension() + return ext == "cs" and GdUnit4CSharpApiLoader.is_api_loaded() diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid new file mode 100644 index 0000000..bca7e38 --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid @@ -0,0 +1 @@ +uid://b8y4p27r37pk1 |
