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