summaryrefslogtreecommitdiff
path: root/addons/gdUnit4/src/dotnet
diff options
context:
space:
mode:
Diffstat (limited to 'addons/gdUnit4/src/dotnet')
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs235
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd114
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid1
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