diff --git a/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContext.cs b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContext.cs new file mode 100644 index 0000000000..5540ba3cdf --- /dev/null +++ b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContext.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using MLAPI; +using MLAPI.Messaging; +using NUnit.Framework; +using NUnit.Framework.Interfaces; +using UnityEngine; +using UnityEngine.TestTools; +using Debug = UnityEngine.Debug; + +/// +/// Allows for context based delegate execution. +/// Can specify where you want that lambda executed (client side? server side?) and it'll automatically wait for the end +/// of a clientRPC server side and vice versa. +/// todo this could be used as an in-game tool too? for protocols that require a lot of back and forth? +/// +public class ExecuteStepInContext : CustomYieldInstruction +{ + public enum StepExecutionContext + { + Server, + Clients + } + + [AttributeUsage(AttributeTargets.Method)] + public class MultiprocessContextBasedTestAttribute : NUnitAttribute, IOuterUnityTestAction + { + public IEnumerator BeforeTest(ITest test) + { + yield return new WaitUntil(() => TestCoordinator.Instance != null && HasRegistered); + } + + public IEnumerator AfterTest(ITest test) + { + yield break; + } + } + + private StepExecutionContext m_ActionContext; + private Action m_StepToExecute; + private string m_CurrentActionId; + + // as a remote worker, I store all available actions so I can execute them when triggered from RPCs + public static Dictionary AllActions = new Dictionary(); + private static Dictionary s_MethodIdCounter = new Dictionary(); + + private NetworkManager m_NetworkManager; + private bool m_IsRegistering; + private List> m_ClientIsFinishedChecks = new List>(); + private Func m_AdditionalIsFinishedWaiter; + + private bool m_WaitMultipleUpdates; + private bool m_IgnoreTimeoutException; + + private float m_StartTime; + private bool isTimingOut => Time.time - m_StartTime > TestCoordinator.MaxWaitTimeoutSec; + private bool shouldExecuteLocally => (m_ActionContext == StepExecutionContext.Server && m_NetworkManager.IsServer) || (m_ActionContext == StepExecutionContext.Clients && !m_NetworkManager.IsServer); + + public static bool IsRegistering; + public static bool HasRegistered; + private static List s_AllClientTestInstances = new List(); // to keep an instance for each tests, so captured context in each step is kept + + /// + /// This MUST be called at the beginning of each test in order to use context based steps. + /// Assumes this is called from same callsite as ExecuteStepInContext (and assumes this is called from IEnumerator, the method full name is unique + /// even with the same method name and different parameters). + /// This relies on the name to be unique for each generated IEnumerator state machines + /// + public static void InitializeContextSteps() + { + var callerMethod = new StackFrame(1).GetMethod(); + var methodIdentifier = GetMethodIdentifier(callerMethod); // since this is called from IEnumerator, this should be a generated class, making it unique + s_MethodIdCounter[methodIdentifier] = 0; + } + + private static string GetMethodIdentifier(MethodBase callerMethod) + { + return callerMethod.DeclaringType.FullName; + } + + internal static void InitializeAllSteps() + { + // registering magically all context based steps + IsRegistering = true; + var registeredMethods = typeof(TestCoordinator).Assembly.GetTypes().SelectMany(t => t.GetMethods()) + .Where(m => m.GetCustomAttributes(typeof(MultiprocessContextBasedTestAttribute), true).Length > 0) + .ToArray(); + var typesWithContextMethods = new HashSet(); + foreach (var method in registeredMethods) + { + typesWithContextMethods.Add(method.ReflectedType); + } + + if (registeredMethods.Length == 0) + { + throw new Exception($"Couldn't find any registered methods for multiprocess testing. Is {nameof(TestCoordinator)} in same assembly as test methods?"); + } + + object[] GetParameterValuesToPassFunc(ParameterInfo[] parameterInfo) + { + object[] parametersToReturn = new object[parameterInfo.Length]; + for (int i = 0; i < parameterInfo.Length; i++) + { + var paramType = parameterInfo[i].GetType(); + object defaultObj = null; + if (paramType.IsValueType) + { + defaultObj = Activator.CreateInstance(paramType); + } + + parametersToReturn[i] = defaultObj; + } + + return parametersToReturn; + } + + foreach (var contextType in typesWithContextMethods) + { + var allConstructors = contextType.GetConstructors(); + if (allConstructors.Length > 1) + { + throw new NotImplementedException("Case not implemented where test has more than one constructor"); + } + + var instance = Activator.CreateInstance(contextType, allConstructors.Length > 0 ? GetParameterValuesToPassFunc(allConstructors[0].GetParameters()) : null); + s_AllClientTestInstances.Add(instance); // keeping that instance so tests can use captured local attributes + + var typeMethodsWithContextSteps = new List(); + foreach (var method in contextType.GetMethods()) + { + if (method.GetCustomAttributes(typeof(MultiprocessContextBasedTestAttribute), true).Length > 0) + { + typeMethodsWithContextSteps.Add(method); + } + } + + foreach (var method in typeMethodsWithContextSteps) + { + var parametersToPass = GetParameterValuesToPassFunc(method.GetParameters()); + var enumerator = (IEnumerator)method.Invoke(instance, parametersToPass.ToArray()); + while (enumerator.MoveNext()) { } + } + } + + IsRegistering = false; + HasRegistered = true; + } + + /// + /// Executes an action with the specified context. This allows writing tests all in the same sequential flow, + /// making it more readable. This allows not having to jump between static client methods and test method + /// + /// context to use. for example, should execute client side? server side? + /// action to execute + /// waits for timeout and just finishes step execution silently + /// parameters to pass to action + /// + /// waits multiple frames before allowing the execution to continue. This means ClientFinishedServerRpc must be called manually + /// + public ExecuteStepInContext(StepExecutionContext actionContext, Action stepToExecute, bool ignoreTimeoutException = false, byte[] paramToPass = default, NetworkManager networkManager = null, bool waitMultipleUpdates = false, Func additionalIsFinishedWaiter = null) + { + m_StartTime = Time.time; + m_IsRegistering = IsRegistering; + m_ActionContext = actionContext; + m_StepToExecute = stepToExecute; + m_WaitMultipleUpdates = waitMultipleUpdates; + m_IgnoreTimeoutException = ignoreTimeoutException; + + if (additionalIsFinishedWaiter != null) + { + m_AdditionalIsFinishedWaiter = additionalIsFinishedWaiter; + } + + if (networkManager == null) + { + networkManager = NetworkManager.Singleton; + } + + m_NetworkManager = networkManager; // todo test using this for multiinstance tests too? + + var callerMethod = new StackFrame(1).GetMethod(); // one skip frame for current method + + var methodId = GetMethodIdentifier(callerMethod); // assumes called from IEnumerator MoveNext, which should be the case since we're a CustomYieldInstruction. This will return a generated class name which should be unique + if (!s_MethodIdCounter.ContainsKey(methodId)) + { + s_MethodIdCounter[methodId] = 0; + } + + string currentActionId = $"{methodId}-{s_MethodIdCounter[methodId]++}"; + m_CurrentActionId = currentActionId; + + if (m_IsRegistering) + { + Assert.That(AllActions, Does.Not.Contain(currentActionId)); // sanity check + AllActions[currentActionId] = this; + } + else + { + if (shouldExecuteLocally) + { + m_StepToExecute.Invoke(paramToPass); + } + else + { + if (networkManager.IsServer) + { + TestCoordinator.Instance.TriggerActionIdClientRpc(currentActionId, paramToPass, + clientRpcParams: new ClientRpcParams + { + Send = new ClientRpcSendParams { TargetClientIds = TestCoordinator.AllClientIdsExceptMine.ToArray() } + }); + foreach (var clientId in TestCoordinator.AllClientIdsExceptMine) + { + m_ClientIsFinishedChecks.Add(TestCoordinator.ConsumeClientIsFinished(clientId)); + } + } + else + { + throw new NotImplementedException(); + } + } + } + } + + public void Invoke(byte[] args) + { + m_StepToExecute.Invoke(args); + if (!m_WaitMultipleUpdates) + { + if (!m_NetworkManager.IsServer) + { + TestCoordinator.Instance.ClientFinishedServerRpc(); + } + else + { + throw new NotImplementedException("todo implement"); + } + } + } + + public override bool keepWaiting + { + get + { + if (isTimingOut) + { + if (m_IgnoreTimeoutException) + { + Debug.LogWarning($"Timeout ignored for action ID {m_CurrentActionId}"); + return false; + } + + throw new Exception($"timeout for Context Step with action ID {m_CurrentActionId}"); + } + + if (m_AdditionalIsFinishedWaiter != null) + { + var isFinished = m_AdditionalIsFinishedWaiter.Invoke(); + if (!isFinished) + { + return true; + } + } + + if (m_IsRegistering || shouldExecuteLocally || m_ClientIsFinishedChecks == null) + { + return false; + } + + for (int i = m_ClientIsFinishedChecks.Count - 1; i >= 0; i--) + { + if (m_ClientIsFinishedChecks[i].Invoke()) + { + m_ClientIsFinishedChecks.RemoveAt(i); + } + else + { + return true; + } + } + + return false; + } + } +} diff --git a/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContext.cs.meta b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContext.cs.meta new file mode 100644 index 0000000000..2b5f013d83 --- /dev/null +++ b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ff1efc1d00c64914905497db918aadc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContextTests.cs b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContextTests.cs new file mode 100644 index 0000000000..d351cbb99d --- /dev/null +++ b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContextTests.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections; +using System.Text.RegularExpressions; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using static ExecuteStepInContext; + +namespace MLAPI.MultiprocessRuntimeTests +{ + /// + /// Smoke tests for ExecuteStepInContext, to make sure it's working properly before being used in other tests + /// + [TestFixture(1)] + [TestFixture(2)] + public class ExecuteStepInContextTests : BaseMultiprocessTests + { + private int m_WorkerCountToTest; + + public ExecuteStepInContextTests(int workerCountToTest) + { + m_WorkerCountToTest = workerCountToTest; + } + + protected override int WorkerCount => m_WorkerCountToTest; + protected override bool m_IsPerformanceTest => false; + + [UnityTest, MultiprocessContextBasedTest] + public IEnumerator TestWithSameName([Values(1)] int a) + { + // ExecuteStepInContext bases itself on method name to identify steps. We need to make sure that methods with + // same names, but different signatures behave correctly + InitializeContextSteps(); + yield return new ExecuteStepInContext(StepExecutionContext.Server, bytes => + { + Assert.That(a, Is.EqualTo(1)); + }); + yield return new ExecuteStepInContext(StepExecutionContext.Clients, bytes => + { + Assert.That(BitConverter.ToInt32(bytes, 0), Is.EqualTo(1)); + }, paramToPass: BitConverter.GetBytes(a)); + } + + [UnityTest, MultiprocessContextBasedTest] + public IEnumerator TestWithSameName([Values(2)] int a, [Values(3)] int b) + { + InitializeContextSteps(); + yield return new ExecuteStepInContext(StepExecutionContext.Server, bytes => + { + Assert.That(b, Is.EqualTo(3)); + }); + yield return new ExecuteStepInContext(StepExecutionContext.Clients, bytes => + { + Assert.That(BitConverter.ToInt32(bytes, 0), Is.EqualTo(3)); + }, paramToPass: BitConverter.GetBytes(b)); + } + + [UnityTest, MultiprocessContextBasedTest] + public IEnumerator TestWithParameters([Values(1, 2, 3)] int a) + { + InitializeContextSteps(); + + yield return new ExecuteStepInContext(StepExecutionContext.Server, bytes => + { + Assert.Less(a, 4); + Assert.Greater(a, 0); + }); + yield return new ExecuteStepInContext(StepExecutionContext.Clients, bytes => + { + var clientA = BitConverter.ToInt32(bytes, 0); + Assert.True(!NetworkManager.Singleton.IsServer); + Assert.Less(clientA, 4); + Assert.Greater(clientA, 0); + }, paramToPass: BitConverter.GetBytes(a)); + } + + [UnityTest, MultiprocessContextBasedTest] + [TestCase(1, 2, ExpectedResult = null)] + [TestCase(2, 3, ExpectedResult = null)] + [TestCase(3, 4, ExpectedResult = null)] + public IEnumerator TestWithParameters(int a, int b) + { + InitializeContextSteps(); + + yield return new ExecuteStepInContext(StepExecutionContext.Server, bytes => + { + Assert.Less(a, 4); + Assert.Greater(a, 0); + Assert.Less(b, 5); + Assert.Greater(b, 1); + }); + yield return new ExecuteStepInContext(StepExecutionContext.Clients, bytes => + { + var clientB = BitConverter.ToInt32(bytes, 0); + Assert.True(!NetworkManager.Singleton.IsServer); + Assert.Less(clientB, 5); + Assert.Greater(clientB, 1); + }, paramToPass: BitConverter.GetBytes(b)); + } + + [UnityTest, MultiprocessContextBasedTest] + public IEnumerator TestExceptionClientSide() + { + InitializeContextSteps(); + + const string exceptionMessageToTest = "This is an exception for TestCoordinator that's expected"; + yield return new ExecuteStepInContext(StepExecutionContext.Clients, _ => + { + throw new Exception(exceptionMessageToTest); + }, ignoreTimeoutException: true); + yield return new ExecuteStepInContext(StepExecutionContext.Server, _ => + { + for (int i = 0; i < m_WorkerCountToTest; i++) + { + LogAssert.Expect(LogType.Error, new Regex($".*{exceptionMessageToTest}.*")); + } + }); + + const string exceptionUpdateMessageToTest = "This is an exception for update loop client side that's expected"; + yield return new ExecuteStepInContext(StepExecutionContext.Clients, _ => + { + void UpdateFunc(float _) + { + NetworkManager.Singleton.gameObject.GetComponent().OnUpdate -= UpdateFunc; + throw new Exception(exceptionUpdateMessageToTest); + } + + NetworkManager.Singleton.gameObject.GetComponent().OnUpdate += UpdateFunc; + }, ignoreTimeoutException: true); + yield return new ExecuteStepInContext(StepExecutionContext.Server, _ => + { + for (int i = 0; i < m_WorkerCountToTest; i++) + { + LogAssert.Expect(LogType.Error, new Regex($".*{exceptionUpdateMessageToTest}.*")); + } + }); + } + + [UnityTest, MultiprocessContextBasedTest] + public IEnumerator ContextTestWithAdditionalWait() + { + InitializeContextSteps(); + + const int maxValue = 10; + yield return new ExecuteStepInContext(StepExecutionContext.Clients, _ => + { + int count = 0; + + void UpdateFunc(float _) + { + TestCoordinator.Instance.WriteTestResultsServerRpc(count++); + if (count > maxValue) + { + NetworkManager.Singleton.gameObject.GetComponent().OnUpdate -= UpdateFunc; + } + } + + NetworkManager.Singleton.gameObject.GetComponent().OnUpdate += UpdateFunc; + }, additionalIsFinishedWaiter: () => + { + int nbFinished = 0; + for (int i = 0; i < m_WorkerCountToTest; i++) + { + if (TestCoordinator.PeekLatestResult(TestCoordinator.AllClientIdsExceptMine[i]) == maxValue) + { + nbFinished++; + } + } + + return nbFinished == m_WorkerCountToTest; + }); + yield return new ExecuteStepInContext(StepExecutionContext.Server, _ => + { + Assert.That(TestCoordinator.AllClientIdsExceptMine.Count, Is.EqualTo(m_WorkerCountToTest)); + foreach (var clientId in TestCoordinator.AllClientIdsExceptMine) + { + var current = 0; + foreach (var res in TestCoordinator.ConsumeCurrentResult(clientId)) + { + Assert.That(res, Is.EqualTo(current++)); + } + + Assert.That(current - 1, Is.EqualTo(maxValue)); + } + }); + } + + [UnityTest, MultiprocessContextBasedTest] + public IEnumerator TestExecuteInContext() + { + InitializeContextSteps(); + + int stepCountExecuted = 0; + yield return new ExecuteStepInContext(StepExecutionContext.Server, args => + { + stepCountExecuted++; + int count = BitConverter.ToInt32(args, 0); + Assert.That(count, Is.EqualTo(1)); + }, paramToPass: BitConverter.GetBytes(1)); + + yield return new ExecuteStepInContext(StepExecutionContext.Clients, args => + { + int count = BitConverter.ToInt32(args, 0); + Assert.That(count, Is.EqualTo(2)); + TestCoordinator.Instance.WriteTestResultsServerRpc(12345); +#if UNITY_EDITOR + Assert.Fail("Should not be here!! This should only execute on client!!"); +#endif + }, paramToPass: BitConverter.GetBytes(2)); + + yield return new ExecuteStepInContext(StepExecutionContext.Server, _ => + { + stepCountExecuted++; + int resultCountFromWorkers = 0; + foreach (var res in TestCoordinator.ConsumeCurrentResult()) + { + resultCountFromWorkers++; + Assert.AreEqual(12345, res.result); + } + + Assert.That(resultCountFromWorkers, Is.EqualTo(WorkerCount)); + }); + + const int timeToWait = 4; + yield return new ExecuteStepInContext(StepExecutionContext.Clients, _ => + { + void UpdateFunc(float _) + { + if (Time.time > timeToWait) + { + NetworkManager.Singleton.gameObject.GetComponent().OnUpdate -= UpdateFunc; + TestCoordinator.Instance.WriteTestResultsServerRpc(Time.time); + + TestCoordinator.Instance.ClientFinishedServerRpc(); // since finishOnInvoke is false, we need to do this manually + } + } + + NetworkManager.Singleton.gameObject.GetComponent().OnUpdate += UpdateFunc; + }, waitMultipleUpdates: true); // waits multiple frames before allowing the next action to continue. + + yield return new ExecuteStepInContext(StepExecutionContext.Server, args => + { + stepCountExecuted++; + int count = 0; + foreach (var res in TestCoordinator.ConsumeCurrentResult()) + { + count++; + Assert.GreaterOrEqual(res.result, timeToWait); + } + + Assert.Greater(count, 0); + }); + + if (!IsRegistering) + { + Assert.AreEqual(3, stepCountExecuted); + } + } + } +} diff --git a/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContextTests.cs.meta b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContextTests.cs.meta new file mode 100644 index 0000000000..3f89ca9cb5 --- /dev/null +++ b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/ExecuteStepInContextTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 80b877babc0ce4b0d8cf31e73216d49a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/testproject/Assets/Tests/Runtime/MultiprocessRuntime/Helpers/CallbackComponent.cs b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/Helpers/CallbackComponent.cs new file mode 100644 index 0000000000..80ecc0dcbd --- /dev/null +++ b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/Helpers/CallbackComponent.cs @@ -0,0 +1,24 @@ +using System; +using UnityEngine; + +/// +/// Component who's purpose is to expose callbacks to code tests +/// +public class CallbackComponent : MonoBehaviour +{ + public Action OnUpdate; + + // Update is called once per frame + private void Update() + { + try + { + OnUpdate?.Invoke(Time.deltaTime); + } + catch (Exception e) + { + TestCoordinator.Instance.WriteErrorServerRpc(e.Message); + throw; + } + } +} diff --git a/testproject/Assets/Tests/Runtime/MultiprocessRuntime/Helpers/CallbackComponent.cs.meta b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/Helpers/CallbackComponent.cs.meta new file mode 100644 index 0000000000..7347e89e7b --- /dev/null +++ b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/Helpers/CallbackComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55d1c75ce242745ac98f7e7aca6d2d19 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/testproject/Assets/Tests/Runtime/MultiprocessRuntime/TestCoordinator.cs b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/TestCoordinator.cs index fc0efec2e1..69d83f358f 100644 --- a/testproject/Assets/Tests/Runtime/MultiprocessRuntime/TestCoordinator.cs +++ b/testproject/Assets/Tests/Runtime/MultiprocessRuntime/TestCoordinator.cs @@ -61,7 +61,7 @@ public void Start() NetworkManager.OnClientDisconnectCallback += OnClientDisconnectCallback; - // ExecuteStepInContext.InitializeAllSteps(); + ExecuteStepInContext.InitializeAllSteps(); } public void Update() @@ -143,9 +143,9 @@ private static string GetMethodInfo(Action method) } } - public static IEnumerable ConsumeCurrentResult(ulong clientID) + public static IEnumerable ConsumeCurrentResult(ulong clientId) { - var allResults = Instance.m_TestResultsLocal[clientID]; + var allResults = Instance.m_TestResultsLocal[clientId]; while (allResults.Count > 0) { var toReturn = allResults[0]; @@ -245,12 +245,12 @@ public void InvokeFromMethodActionRpc(Action methodInfo) } [ClientRpc] - public void TriggerActionIDClientRpc(string actionID, byte[] args, ClientRpcParams clientRpcParams = default) + public void TriggerActionIdClientRpc(string actionId, byte[] args, ClientRpcParams clientRpcParams = default) { - Debug.Log($"received RPC from server, client side triggering action ID {actionID}"); + Debug.Log($"received RPC from server, client side triggering action ID {actionId}"); try { - //ExecuteStepInContext.AllActions[actionId].Invoke(args); + ExecuteStepInContext.AllActions[actionId].Invoke(args); } catch (Exception e) {