diff --git a/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef b/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef index b16dcfa74a..fcf34e787c 100644 --- a/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef +++ b/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef @@ -6,15 +6,15 @@ "GUID:c956a21f824994ef087b6de566690b3d", "GUID:4307f53044263cf4b835bd812fc161a4" ], - "includePlatforms": [ - "Editor" - ], + "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], "versionDefines": [ { "name": "com.unity.inputsystem", diff --git a/Assets/Tests/Editor/InputVisualizationCanvasPrefabTests.cs b/Assets/Tests/Editor/InputVisualizationCanvasPrefabTests.cs new file mode 100644 index 0000000000..5b16d846d7 --- /dev/null +++ b/Assets/Tests/Editor/InputVisualizationCanvasPrefabTests.cs @@ -0,0 +1,103 @@ +#nullable enable +using System; +using System.IO; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.uLoopMCP +{ + /// + /// Verifies that the shared input visualization prefab keeps attachable overlay components. + /// + public sealed class InputVisualizationCanvasPrefabTests + { + private const string PrefabPath = + "Packages/io.github.hatayama.uloopmcp/Runtime/Common/InputVisualizationCanvas.prefab"; + private const string RuntimeAssemblyDefinitionPath = + "Packages/src/Runtime/uLoopMCP.Runtime.asmdef"; + private const string SimulateMouseInputOverlayPrefabPath = + "Packages/src/Runtime/SimulateMouseInput/SimulateMouseInputOverlay.prefab"; + private const string DemoMouseInputOverlayTesterMetaPath = + "Assets/Tests/Demo/DemoMouseInputOverlayTester.cs.meta"; + + [Test] + public void RuntimeAssemblyDefinition_WhenScanned_IsPrefabAttachableAndNotAutoReferenced() + { + // The runtime assembly must stay attachable for prefab scripts while avoiding implicit user assembly references. + JObject asmdef = JObject.Parse(ReadProjectText(RuntimeAssemblyDefinitionPath)); + JToken? includePlatformsToken = asmdef["includePlatforms"]; + + Assert.That(includePlatformsToken, Is.Not.Null); + Assert.That(includePlatformsToken!.Type, Is.EqualTo(JTokenType.Array)); + Assert.That(includePlatformsToken.HasValues, Is.False); + Assert.That(asmdef["autoReferenced"]?.Value(), Is.False); + } + + [Test] + public void InputVisualizationCanvasPrefab_WhenInstantiated_HasOverlayReferences() + { + // Instantiation proves Unity can resolve the prefab scripts after import, not only in raw YAML. + GameObject prefab = AssetDatabase.LoadAssetAtPath(PrefabPath); + + Assert.That(prefab, Is.Not.Null); + + UnityEngine.Object instantiatedObject = PrefabUtility.InstantiatePrefab(prefab); + Assert.That(instantiatedObject, Is.TypeOf()); + + GameObject instance = (GameObject)instantiatedObject; + try + { + InputVisualizationCanvas canvas = instance.GetComponent(); + + Assert.That(canvas, Is.Not.Null); + Assert.That(canvas.KeyboardOverlay, Is.Not.Null); + Assert.That(canvas.MouseUiOverlay, Is.Not.Null); + Assert.That(canvas.MouseInputOverlay, Is.Not.Null); + Assert.That(canvas.RecordInputOverlayPresenter, Is.Not.Null); + Assert.That(canvas.ReplayInputOverlay, Is.Not.Null); + } + finally + { + UnityEngine.Object.DestroyImmediate(instance); + } + } + + [Test] + public void SimulateMouseInputOverlayPrefab_WhenSerialized_DoesNotReferenceDemoTester() + { + // Package prefabs cannot reference demo/test scripts because consumers import only Packages/src. + string prefabText = ReadProjectText(SimulateMouseInputOverlayPrefabPath); + string demoTesterGuid = ReadMetaGuid(DemoMouseInputOverlayTesterMetaPath); + + Assert.That(prefabText, Does.Not.Contain(demoTesterGuid)); + } + + private static string ReadProjectText(string relativePath) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string absolutePath = Path.Combine(projectRoot, relativePath); + + return File.ReadAllText(absolutePath); + } + + private static string ReadMetaGuid(string relativePath) + { + string[] lines = ReadProjectText(relativePath).Split('\n'); + const string Prefix = "guid: "; + + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.StartsWith(Prefix, StringComparison.Ordinal)) + { + return line.Substring(Prefix.Length).Trim(); + } + } + + Assert.Fail("Meta file must contain a guid line"); + return string.Empty; + } + } +} diff --git a/Assets/Tests/Editor/InputVisualizationCanvasPrefabTests.cs.meta b/Assets/Tests/Editor/InputVisualizationCanvasPrefabTests.cs.meta new file mode 100644 index 0000000000..34547642a6 --- /dev/null +++ b/Assets/Tests/Editor/InputVisualizationCanvasPrefabTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eff3e2ca33fd44eaa80d8954b7cec566 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/RuntimeAssemblyBuildFilterTests.cs b/Assets/Tests/Editor/RuntimeAssemblyBuildFilterTests.cs new file mode 100644 index 0000000000..f9f9520e8c --- /dev/null +++ b/Assets/Tests/Editor/RuntimeAssemblyBuildFilterTests.cs @@ -0,0 +1,47 @@ +using NUnit.Framework; +using UnityEditor; + +namespace io.github.hatayama.uLoopMCP +{ + /// + /// Verifies that the Player build callback removes only the prefab-only runtime assembly. + /// + public sealed class RuntimeAssemblyBuildFilterTests + { + [Test] + public void OnFilterAssemblies_WhenRuntimeAssemblyIsPresent_RemovesOnlyRuntimeAssembly() + { + RuntimeAssemblyBuildFilter filter = new RuntimeAssemblyBuildFilter(); + string[] assemblies = + { + "/build/Managed/Assembly-CSharp.dll", + "/build/Managed/uLoopMCP.Runtime.dll", + "/build/Managed/UnityEngine.CoreModule.dll" + }; + + string[] filteredAssemblies = filter.OnFilterAssemblies(BuildOptions.None, assemblies); + + Assert.That(filteredAssemblies, Is.EqualTo(new[] + { + "/build/Managed/Assembly-CSharp.dll", + "/build/Managed/UnityEngine.CoreModule.dll" + })); + } + + [Test] + public void OnFilterAssemblies_WhenTestAssembliesAreIncluded_KeepsRuntimeAssembly() + { + RuntimeAssemblyBuildFilter filter = new RuntimeAssemblyBuildFilter(); + string[] assemblies = + { + "/build/Managed/Assembly-CSharp.dll", + "/build/Managed/uLoopMCP.Runtime.dll", + "/build/Managed/uLoopMCP.Tests.PlayMode.dll" + }; + + string[] filteredAssemblies = filter.OnFilterAssemblies(BuildOptions.IncludeTestAssemblies, assemblies); + + Assert.That(filteredAssemblies, Is.EqualTo(assemblies)); + } + } +} diff --git a/Assets/Tests/Editor/RuntimeAssemblyBuildFilterTests.cs.meta b/Assets/Tests/Editor/RuntimeAssemblyBuildFilterTests.cs.meta new file mode 100644 index 0000000000..0b6f9c367a --- /dev/null +++ b/Assets/Tests/Editor/RuntimeAssemblyBuildFilterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25812280c766b4d0f8aa505c53df1439 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/uLoopMCP.Tests.Editor.asmdef b/Assets/Tests/Editor/uLoopMCP.Tests.Editor.asmdef index 08ab1ee979..c9c589dc94 100644 --- a/Assets/Tests/Editor/uLoopMCP.Tests.Editor.asmdef +++ b/Assets/Tests/Editor/uLoopMCP.Tests.Editor.asmdef @@ -7,6 +7,7 @@ "GUID:27619889b8ba8c24980f49ee34dbb44a", "GUID:332048ead1ebf4658b9961338e5d68c3", "GUID:290394860909340b7835eb7cc215ee75", + "GUID:c956a21f824994ef087b6de566690b3d", "uLoopMCP.Editor.MetadataValidation" ], "includePlatforms": [ diff --git a/Assets/Tests/PlayMode/InputReplayerCompletionTests.cs b/Assets/Tests/PlayMode/InputReplayerCompletionTests.cs new file mode 100644 index 0000000000..d0ea5bd582 --- /dev/null +++ b/Assets/Tests/PlayMode/InputReplayerCompletionTests.cs @@ -0,0 +1,269 @@ +#if ULOOPMCP_HAS_INPUT_SYSTEM +#nullable enable +using System.Collections; +using System.Collections.Generic; +using io.github.hatayama.uLoopMCP; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.UI; +using UnityEngine.TestTools; +using UnityEngine.UI; + +namespace Tests.PlayMode +{ + /// + /// Verifies that replay completion waits for UI modules to consume the final injected input frame. + /// + public sealed class InputReplayerCompletionTests + { + private const float ReplayTimeoutSeconds = 5f; + private const float ButtonWidth = 240f; + private const float ButtonHeight = 120f; + + private GameObject _canvasGo = null!; + private GameObject _eventSystemGo = null!; + private GameObject _buttonGo = null!; + private Mouse? _createdMouse; + private bool _replayCompleted; + private int _clickCount; + private int _clickCountAtCompletion; + private bool _observedLoopFrameBeyondTotal; + private bool _observedLoopReset; + private int _loopObservationTicks; + private PointerLifecycleTracker _pointerLifecycleTracker = null!; + + [UnitySetUp] + public IEnumerator SetUp() + { + _replayCompleted = false; + _clickCount = 0; + _clickCountAtCompletion = 0; + _observedLoopFrameBeyondTotal = false; + _observedLoopReset = false; + _loopObservationTicks = 0; + + EnsureMouseDeviceExists(); + CreateScene(); + + yield return null; + } + + [UnityTearDown] + public IEnumerator TearDown() + { + InputReplayer.ReplayCompleted -= OnReplayCompleted; + InputSystem.onAfterUpdate -= ObserveLoopReplayCadence; + + if (InputReplayer.IsReplaying) + { + InputReplayer.StopReplay(); + } + + if (_createdMouse != null) + { + InputSystem.RemoveDevice(_createdMouse); + _createdMouse = null; + } + + Object.Destroy(_buttonGo); + Object.Destroy(_eventSystemGo); + Object.Destroy(_canvasGo); + + yield return null; + } + + [UnityTest] + public IEnumerator ReplayCompleted_WhenInputSystemUiConsumesTerminalRelease_FiresAfterClick() + { + Vector2 screenPosition = RectTransformUtility.WorldToScreenPoint(null, _buttonGo.transform.position); + InputRecordingData recording = CreateTerminalClickRecording(screenPosition); + + InputReplayer.ReplayCompleted += OnReplayCompleted; + InputReplayer.StartReplay(recording, loop: false, showOverlay: false); + + float timeoutAt = Time.realtimeSinceStartup + ReplayTimeoutSeconds; + yield return new WaitUntil(() => + _replayCompleted || Time.realtimeSinceStartup >= timeoutAt); + + Assert.IsTrue(_replayCompleted, $"Replay did not complete within {ReplayTimeoutSeconds}s"); + Assert.AreEqual(1, _clickCountAtCompletion, + "ReplayCompleted must fire after InputSystemUIInputModule processes the terminal release"); + Assert.AreEqual(1, _clickCount, "Button click must be processed exactly once"); + } + + [UnityTest] + public IEnumerator LoopReplay_WhenInputSystemUiConsumesTerminalRelease_DoesNotInsertEmptyFrame() + { + Vector2 screenPosition = RectTransformUtility.WorldToScreenPoint(null, _buttonGo.transform.position); + InputRecordingData recording = CreateTerminalClickRecording(screenPosition); + + InputReplayer.StartReplay(recording, loop: true, showOverlay: false); + InputSystem.onAfterUpdate += ObserveLoopReplayCadence; + + float timeoutAt = Time.realtimeSinceStartup + ReplayTimeoutSeconds; + yield return new WaitUntil(() => + _observedLoopReset || Time.realtimeSinceStartup >= timeoutAt); + + Assert.IsTrue(_observedLoopReset, $"Looped replay did not reset within {ReplayTimeoutSeconds}s"); + Assert.IsFalse( + _observedLoopFrameBeyondTotal, + "Looped replay must reset on the terminal release without exposing an unrecorded empty frame"); + } + + [UnityTest] + public IEnumerator Replay_WhenInputSystemUiModuleIsSelectedAfterManualPress_FiresMatchingPointerUp() + { + RecreateEventSystemWithoutSelectedInputModule(); + EventSystem? eventSystem = EventSystem.current; + Assert.IsNotNull(eventSystem, "EventSystem.current must be assigned"); + Assert.IsNull(eventSystem!.currentInputModule, "Fresh EventSystem should not select an input module until update"); + + Vector2 screenPosition = RectTransformUtility.WorldToScreenPoint(null, _buttonGo.transform.position); + InputRecordingData recording = CreateTerminalClickRecording(screenPosition); + + InputReplayer.ReplayCompleted += OnReplayCompleted; + InputReplayer.StartReplay(recording, loop: false, showOverlay: false); + + float timeoutAt = Time.realtimeSinceStartup + ReplayTimeoutSeconds; + yield return new WaitUntil(() => + _replayCompleted || Time.realtimeSinceStartup >= timeoutAt); + + Assert.IsTrue(_replayCompleted, $"Replay did not complete within {ReplayTimeoutSeconds}s"); + Assert.Greater(_pointerLifecycleTracker.PointerDownCount, 0, + "The replay should dispatch at least one pointer down to the target"); + Assert.AreEqual(_pointerLifecycleTracker.PointerDownCount, _pointerLifecycleTracker.PointerUpCount, + "Every pointer down must receive a matching pointer up even if the input module is selected later"); + } + + private void OnReplayCompleted() + { + _clickCountAtCompletion = _clickCount; + _replayCompleted = true; + } + + private void ObserveLoopReplayCadence() + { + if (!InputReplayer.IsReplaying) + { + return; + } + + if (InputReplayer.CurrentFrame > InputReplayer.TotalFrames) + { + _observedLoopFrameBeyondTotal = true; + } + + if (_loopObservationTicks > 0 && InputReplayer.CurrentFrame == 0) + { + _observedLoopReset = true; + } + + _loopObservationTicks++; + } + + private void OnButtonClicked() + { + _clickCount++; + } + + private void EnsureMouseDeviceExists() + { + if (Mouse.current != null) + { + return; + } + + _createdMouse = InputSystem.AddDevice(); + } + + private void CreateScene() + { + _canvasGo = new GameObject("InputReplayerCompletionCanvas"); + Canvas canvas = _canvasGo.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + _canvasGo.AddComponent(); + + _eventSystemGo = new GameObject("InputReplayerCompletionEventSystem"); + _eventSystemGo.AddComponent(); + _eventSystemGo.AddComponent(); + + _buttonGo = new GameObject("TerminalReleaseButton"); + _buttonGo.transform.SetParent(_canvasGo.transform, false); + RectTransform buttonRect = _buttonGo.AddComponent(); + buttonRect.sizeDelta = new Vector2(ButtonWidth, ButtonHeight); + Image buttonImage = _buttonGo.AddComponent(); + Button button = _buttonGo.AddComponent