diff --git a/Assets/Tests/PlayMode/SimulateMouseUiInputSystemTests.cs b/Assets/Tests/PlayMode/SimulateMouseUiInputSystemTests.cs new file mode 100644 index 0000000000..c4686eba16 --- /dev/null +++ b/Assets/Tests/PlayMode/SimulateMouseUiInputSystemTests.cs @@ -0,0 +1,222 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Collections; +using System.Threading; +using System.Threading.Tasks; +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.InputSystem; +using UnityEngine.TestTools; +using UnityEngine.UI; +using Object = UnityEngine.Object; + +namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode +{ + /// + /// Test fixture that verifies mouse UI drags keep Mouse.current aligned with the simulated pointer position. + /// + public class SimulateMouseUiInputSystemTests : InputTestFixture + { + private const float PositionTolerance = 0.01f; + + private GameObject canvasGo = null!; + private GameObject eventSystemGo = null!; + private ExistingEventSystemDisableScope eventSystemDisableScope = null!; + private SimulateMouseUiTool tool = null!; + private SimulateMouseUiResponse lastResponse = null!; + + public override void Setup() + { + base.Setup(); + + eventSystemDisableScope = new ExistingEventSystemDisableScope(); + + canvasGo = new GameObject("TestCanvas"); + Canvas canvas = canvasGo.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvasGo.AddComponent(); + canvasGo.AddComponent(); + + eventSystemGo = new GameObject("TestEventSystem"); + eventSystemGo.AddComponent(); + eventSystemGo.AddComponent(); + + tool = new SimulateMouseUiTool(); + InputSystem.AddDevice(); + } + + public override void TearDown() + { + MouseDragState.Clear(); + Object.DestroyImmediate(canvasGo); + Object.DestroyImmediate(eventSystemGo); + eventSystemDisableScope.Restore(); + base.TearDown(); + } + + [UnityTest] + public IEnumerator DragOneShot_Should_UpdateMouseCurrentPositionToDragPosition() + { + // Verifies a one-shot UI drag keeps Mouse.current aligned with PointerEventData at the drag end. + MouseAwareDragTracker tracker = CreateDraggableElement( + "DragTarget", new Vector2(120f, 80f), new Vector2(200f, 100f)); + yield return null; + + Vector2 startScreenPosition = GetScreenPosition(tracker.gameObject); + Vector2 endScreenPosition = startScreenPosition + new Vector2(140f, -60f); + Vector2 startInputPosition = ScreenToInput(startScreenPosition); + Vector2 endInputPosition = ScreenToInput(endScreenPosition); + SetMousePosition(Vector2.zero); + + yield return RunTool(new JObject + { + ["action"] = MouseAction.Drag.ToString(), + ["fromX"] = startInputPosition.x, + ["fromY"] = startInputPosition.y, + ["x"] = endInputPosition.x, + ["y"] = endInputPosition.y, + ["dragSpeed"] = 0f + }); + + Assert.IsTrue(lastResponse.Success); + AssertPositionEquals(endScreenPosition, tracker.LastPointerPosition, "PointerEventData position should reach the drag end."); + AssertPositionEquals(endScreenPosition, tracker.LastMousePosition, "Mouse.current position should match PointerEventData during drag."); + } + + [UnityTest] + public IEnumerator DragSplit_Should_UpdateMouseCurrentPositionOnEachDragStep() + { + // Verifies the incremental drag (DragStart/DragMove/DragEnd) path keeps Mouse.current aligned too, + // covering the InitiateDrag and InterpolateDragPosition sync points independently of one-shot drag. + MouseAwareDragTracker tracker = CreateDraggableElement( + "DragTarget", Vector2.zero, new Vector2(200f, 100f)); + yield return null; + + Vector2 startScreenPosition = GetScreenPosition(tracker.gameObject); + Vector2 moveScreenPosition = startScreenPosition + new Vector2(50f, 0f); + Vector2 endScreenPosition = startScreenPosition + new Vector2(100f, 0f); + Vector2 startInputPosition = ScreenToInput(startScreenPosition); + Vector2 moveInputPosition = ScreenToInput(moveScreenPosition); + Vector2 endInputPosition = ScreenToInput(endScreenPosition); + SetMousePosition(Vector2.zero); + + yield return RunTool(new JObject + { + ["action"] = MouseAction.DragStart.ToString(), + ["x"] = startInputPosition.x, + ["y"] = startInputPosition.y + }); + Assert.IsTrue(lastResponse.Success); + AssertPositionEquals(startScreenPosition, GetMousePosition(), "Mouse.current position should match the drag start position."); + + yield return RunTool(new JObject + { + ["action"] = MouseAction.DragMove.ToString(), + ["x"] = moveInputPosition.x, + ["y"] = moveInputPosition.y, + ["dragSpeed"] = 0f + }); + Assert.IsTrue(lastResponse.Success); + AssertPositionEquals(moveScreenPosition, GetMousePosition(), "Mouse.current position should match the drag move position."); + + yield return RunTool(new JObject + { + ["action"] = MouseAction.DragEnd.ToString(), + ["x"] = endInputPosition.x, + ["y"] = endInputPosition.y, + ["dragSpeed"] = 0f + }); + Assert.IsTrue(lastResponse.Success); + AssertPositionEquals(endScreenPosition, GetMousePosition(), "Mouse.current position should match the drag end position."); + } + + private IEnumerator RunTool(JObject parameters) + { + Task task = tool.ExecuteAsync(parameters, CancellationToken.None); + float timeoutAt = Time.realtimeSinceStartup + 5f; + yield return new WaitUntil(() => + task.IsCompleted || Time.realtimeSinceStartup >= timeoutAt); + Assert.IsTrue(task.IsCompleted, "Tool execution timed out."); + Assert.IsFalse(task.IsFaulted, $"Tool execution should not fault: {task.Exception}"); + lastResponse = (SimulateMouseUiResponse)task.Result; + } + + private MouseAwareDragTracker CreateDraggableElement( + string name, Vector2 anchoredPosition, Vector2 sizeDelta) + { + GameObject go = new GameObject(name); + go.transform.SetParent(canvasGo.transform, false); + RectTransform rect = go.AddComponent(); + rect.anchorMin = new Vector2(0.5f, 0.5f); + rect.anchorMax = new Vector2(0.5f, 0.5f); + rect.anchoredPosition = anchoredPosition; + rect.sizeDelta = sizeDelta; + go.AddComponent(); + return go.AddComponent(); + } + + private Vector2 GetScreenPosition(GameObject go) + { + return (Vector2)go.GetComponent().position; + } + + private Vector2 ScreenToInput(Vector2 screenPosition) + { + float targetHeight = Handles.GetMainGameViewSize().y; + return new Vector2(screenPosition.x, targetHeight - screenPosition.y); + } + + private void SetMousePosition(Vector2 position) + { + Mouse? currentMouse = Mouse.current; + Assert.IsNotNull(currentMouse, "Mouse.current should exist after adding a Mouse device."); + Set(currentMouse!.position, position); + } + + private Vector2 GetMousePosition() + { + Mouse? currentMouse = Mouse.current; + Assert.IsNotNull(currentMouse, "Mouse.current should exist after adding a Mouse device."); + return currentMouse!.position.ReadValue(); + } + + private void AssertPositionEquals(Vector2 expected, Vector2 actual, string message) + { + float distance = Vector2.Distance(expected, actual); + Assert.LessOrEqual(distance, PositionTolerance, $"{message} Expected {expected}, got {actual}."); + } + } + + /// + /// Test support type that records both PointerEventData and Mouse.current positions observed during a drag. + /// + public class MouseAwareDragTracker : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler + { + public Vector2 LastPointerPosition { get; private set; } + public Vector2 LastMousePosition { get; private set; } + + public void OnBeginDrag(PointerEventData eventData) + { + } + + public void OnDrag(PointerEventData eventData) + { + Mouse? currentMouse = Mouse.current; + Assert.IsNotNull(currentMouse, "Mouse.current should exist for this test."); + + LastPointerPosition = eventData.position; + LastMousePosition = currentMouse!.position.ReadValue(); + } + + public void OnEndDrag(PointerEventData eventData) + { + } + } +} +#endif diff --git a/Assets/Tests/PlayMode/SimulateMouseUiInputSystemTests.cs.meta b/Assets/Tests/PlayMode/SimulateMouseUiInputSystemTests.cs.meta new file mode 100644 index 0000000000..41c3014f11 --- /dev/null +++ b/Assets/Tests/PlayMode/SimulateMouseUiInputSystemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b16da86e84b144e7a2c869be8a6b7751 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/AssemblyInfo.cs index 1218d431bd..3976368411 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/AssemblyInfo.cs @@ -6,6 +6,7 @@ [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.ReplayInput.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Demo.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragEventExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragEventExecutor.cs index a318732dfd..8accd4f13b 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragEventExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragEventExecutor.cs @@ -31,6 +31,7 @@ internal static PointerEventData InitiateDrag( pointerDrag = dragTarget, rawPointerPress = raycastResult.gameObject }; + MouseUiInputSystemSync.SyncMousePosition(screenPos); // Slider.OnPointerDown initializes m_Offset for handle positioning GameObject? pressTarget = ExecuteEvents.ExecuteHierarchy( @@ -105,6 +106,7 @@ internal static async Task InterpolateDragPosition( Vector2 previousPosition = pointerData.position; pointerData.position = endPos; pointerData.delta = endPos - previousPosition; + MouseUiInputSystemSync.SyncMousePosition(endPos); ExecuteEvents.Execute(target, pointerData, ExecuteEvents.dragHandler); SimulateMouseUiOverlayState.UpdatePosition(MouseUiCoordinateConverter.ScreenToInput(endPos)); @@ -131,6 +133,7 @@ internal static async Task InterpolateDragPosition( pointerData.position = currentPosition; pointerData.delta = currentPosition - previousPosition; + MouseUiInputSystemSync.SyncMousePosition(currentPosition); ExecuteEvents.Execute(target, pointerData, ExecuteEvents.dragHandler); SimulateMouseUiOverlayState.UpdatePosition(MouseUiCoordinateConverter.ScreenToInput(currentPosition)); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiInputSystemSync.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiInputSystemSync.cs new file mode 100644 index 0000000000..7c8f206b40 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiInputSystemSync.cs @@ -0,0 +1,29 @@ +#nullable enable +using UnityEngine; +#if ULOOP_HAS_INPUT_SYSTEM +using UnityEngine.InputSystem; +#endif + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Keeps Mouse.current aligned with the pointer position driven by mouse UI simulation. + /// + internal static class MouseUiInputSystemSync + { + // UI handlers can read Mouse.current alongside PointerEventData, so both paths must observe one position. + internal static void SyncMousePosition(Vector2 screenPos) + { +#if ULOOP_HAS_INPUT_SYSTEM + Mouse? mouse = Mouse.current; + if (mouse == null) + { + return; + } + + InputSystem.QueueDeltaStateEvent(mouse.position, screenPos); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); +#endif + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiInputSystemSync.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiInputSystemSync.cs.meta new file mode 100644 index 0000000000..ea6645487f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiInputSystemSync.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6e33ce94f024d3cb2f0e1240db6098d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs index 5cb3b556c3..ea4e9cc384 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs @@ -24,6 +24,7 @@ internal static async Task ExecuteClick( Vector2 inputPos = new(parameters.X, parameters.Y); Vector2 screenPos = MouseUiCoordinateConverter.InputToScreen(inputPos); PointerEventData pointerData = MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, screenPos, parameters.Button); + MouseUiInputSystemSync.SyncMousePosition(screenPos); ResolvedPointerTargets resolvedTargets = MouseUiPointerTargetResolver.ResolvePressablePointerTargets(parameters, eventSystem, inputPos, screenPos, pointerData, MouseAction.Click); if (resolvedTargets.FailureResponse != null) @@ -90,6 +91,7 @@ internal static async Task ExecuteLongPress( Vector2 inputPos = new(parameters.X, parameters.Y); Vector2 screenPos = MouseUiCoordinateConverter.InputToScreen(inputPos); PointerEventData pointerData = MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, screenPos, parameters.Button); + MouseUiInputSystemSync.SyncMousePosition(screenPos); ResolvedPointerTargets resolvedTargets = MouseUiPointerTargetResolver.ResolvePressablePointerTargets(parameters, eventSystem, inputPos, screenPos, pointerData, MouseAction.LongPress); if (resolvedTargets.FailureResponse != null) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor.asmdef index cf0b606d20..4873b93fe0 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor.asmdef @@ -8,7 +8,9 @@ "GUID:a2d87883023de4cf59b7d1962d5dd8aa", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:c956a21f824994ef087b6de566690b3d", - "GUID:33fef506e6744f2982e8c13b1196f696" + "GUID:33fef506e6744f2982e8c13b1196f696", + "GUID:45d3617c96fa64d3e95383450e67f25a", + "GUID:75469ad4d38634e559750d17036d5f7c" ], "includePlatforms": [ "Editor" @@ -19,6 +21,12 @@ "precompiledReferences": [], "autoReferenced": false, "defineConstraints": [], - "versionDefines": [], + "versionDefines": [ + { + "name": "com.unity.inputsystem", + "expression": "", + "define": "ULOOP_HAS_INPUT_SYSTEM" + } + ], "noEngineReferences": false }