From 1a9b4adfa32970b658491c8daffc0c11577b06ac Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 04:41:22 +0900 Subject: [PATCH 1/2] Characterize mouse UI pointer target resolution Expose the existing resolver entries at their final internal visibility and pin path lookup, drop handling, bypass hierarchy, pointer state, and no-hit behavior with synchronous EditMode tests before moving the implementation. --- .../MouseUiPointerTargetResolverTests.cs | 350 ++++++++++++++++++ .../MouseUiPointerTargetResolverTests.cs.meta | 11 + .../SimulateMouseUi/SimulateMouseUiUseCase.cs | 12 +- 3 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs create mode 100644 Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs.meta diff --git a/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs b/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs new file mode 100644 index 0000000000..71febacd6d --- /dev/null +++ b/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs @@ -0,0 +1,350 @@ +#nullable enable +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.EventSystems; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Characterizes pointer target resolution before it is extracted from the mouse UI use case. + /// + [TestFixture] + public sealed class MouseUiPointerTargetResolverTests + { + private readonly List createdObjects = new(); + + [TearDown] + public void TearDown() + { + for (int index = createdObjects.Count - 1; index >= 0; index--) + { + GameObject gameObject = createdObjects[index]; + if (gameObject != null) + { + Object.DestroyImmediate(gameObject); + } + } + + createdObjects.Clear(); + } + + /// + /// Verifies pointer press data preserves position and runtime mouse button mapping. + /// + [Test] + public void CreatePointerPressData_WithRightButton_MapsPressState() + { + EventSystem eventSystem = CreateEventSystem(); + Vector2 screenPosition = new(12.5f, 34.5f); + + PointerEventData pointerData = + SimulateMouseUiUseCase.CreatePointerPressData(eventSystem, screenPosition, MouseButton.Right); + + Assert.That(pointerData.position, Is.EqualTo(screenPosition)); + Assert.That(pointerData.pressPosition, Is.EqualTo(screenPosition)); + Assert.That(pointerData.button, Is.EqualTo(PointerEventData.InputButton.Right)); + } + + /// + /// Verifies an exact unique hierarchy path resolves to its active GameObject. + /// + [Test] + public void TryResolveGameObjectPath_WithUniquePath_ReturnsTarget() + { + GameObject root = CreateGameObject("MouseUiTargetResolverTests_UniqueRoot"); + GameObject expectedTarget = CreateGameObject("Target", root.transform); + string targetPath = GameObjectPathUtility.GetFullPath(expectedTarget); + Vector2 inputPosition = new(10f, 20f); + + bool resolved = SimulateMouseUiUseCase.TryResolveGameObjectPath( + targetPath, + "TargetPath", + MouseAction.Click, + inputPosition, + out GameObject? target, + out SimulateMouseUiResponse? failureResponse); + + Assert.That(resolved, Is.True); + Assert.That(target, Is.SameAs(expectedTarget)); + Assert.That(failureResponse, Is.Null); + } + + /// + /// Verifies a missing hierarchy path returns the exact wire-visible failure response. + /// + [Test] + public void TryResolveGameObjectPath_WithMissingPath_ReturnsNotFoundFailure() + { + string targetPath = "MouseUiTargetResolverTests_MissingRoot/Target"; + Vector2 inputPosition = new(10f, 20f); + + bool resolved = SimulateMouseUiUseCase.TryResolveGameObjectPath( + targetPath, + "TargetPath", + MouseAction.Click, + inputPosition, + out GameObject? target, + out SimulateMouseUiResponse? failureResponse); + + Assert.That(resolved, Is.False); + Assert.That(target, Is.Null); + Assert.That(failureResponse, Is.Not.Null); + Assert.That(failureResponse!.Success, Is.False); + Assert.That(failureResponse.Message, Is.EqualTo($"TargetPath '{targetPath}' was not found.")); + Assert.That(failureResponse.Action, Is.EqualTo(MouseAction.Click.ToString())); + Assert.That(failureResponse.PositionX, Is.EqualTo(inputPosition.x)); + Assert.That(failureResponse.PositionY, Is.EqualTo(inputPosition.y)); + } + + /// + /// Verifies duplicate hierarchy paths return the exact ambiguity count and no target. + /// + [Test] + public void TryResolveGameObjectPath_WithDuplicatePath_ReturnsAmbiguousFailure() + { + GameObject firstRoot = CreateGameObject("MouseUiTargetResolverTests_DuplicateRoot"); + GameObject firstTarget = CreateGameObject("Target", firstRoot.transform); + GameObject secondRoot = CreateGameObject("MouseUiTargetResolverTests_DuplicateRoot"); + CreateGameObject("Target", secondRoot.transform); + string targetPath = GameObjectPathUtility.GetFullPath(firstTarget); + Vector2 inputPosition = new(10f, 20f); + + bool resolved = SimulateMouseUiUseCase.TryResolveGameObjectPath( + targetPath, + "TargetPath", + MouseAction.Click, + inputPosition, + out GameObject? target, + out SimulateMouseUiResponse? failureResponse); + + Assert.That(resolved, Is.False); + Assert.That(target, Is.Null); + Assert.That(failureResponse, Is.Not.Null); + Assert.That( + failureResponse!.Message, + Is.EqualTo( + $"TargetPath '{targetPath}' matched 2 active GameObjects. Use a unique hierarchy path.")); + } + + /// + /// Verifies an empty drop path succeeds without resolving an explicit target. + /// + [Test] + public void TryResolveDropTargetPath_WithoutPath_ReturnsNoTarget() + { + MouseUiSimulationCommand command = CreateCommand(new SimulateMouseUiSchema + { + Action = UnityCliLoopMouseUiAction.Drag, + DropTargetPath = " " + }); + + bool resolved = SimulateMouseUiUseCase.TryResolveDropTargetPath( + command, + MouseAction.Drag, + new Vector2(10f, 20f), + out GameObject? dropTarget, + out SimulateMouseUiResponse? failureResponse); + + Assert.That(resolved, Is.True); + Assert.That(dropTarget, Is.Null); + Assert.That(failureResponse, Is.Null); + } + + /// + /// Verifies an explicit drop target without a handler returns the exact failure response. + /// + [Test] + public void TryResolveDropTargetPath_WithoutDropHandler_ReturnsFailure() + { + GameObject root = CreateGameObject("MouseUiTargetResolverTests_DropRoot"); + GameObject target = CreateGameObject("DropTarget", root.transform); + string targetPath = GameObjectPathUtility.GetFullPath(target); + MouseUiSimulationCommand command = CreateCommand(new SimulateMouseUiSchema + { + Action = UnityCliLoopMouseUiAction.Drag, + DropTargetPath = targetPath + }); + Vector2 inputPosition = new(10f, 20f); + + bool resolved = SimulateMouseUiUseCase.TryResolveDropTargetPath( + command, + MouseAction.Drag, + inputPosition, + out GameObject? dropTarget, + out SimulateMouseUiResponse? failureResponse); + + Assert.That(resolved, Is.False); + Assert.That(dropTarget, Is.Null); + Assert.That(failureResponse, Is.Not.Null); + Assert.That( + failureResponse!.Message, + Is.EqualTo($"DropTargetPath '{targetPath}' has no drop handler.")); + Assert.That(failureResponse.Action, Is.EqualTo(MouseAction.Drag.ToString())); + Assert.That(failureResponse.PositionX, Is.EqualTo(inputPosition.x)); + Assert.That(failureResponse.PositionY, Is.EqualTo(inputPosition.y)); + } + + /// + /// Verifies an explicit drop target with a hierarchy handler returns its raw target. + /// + [Test] + public void TryResolveDropTargetPath_WithDropHandler_ReturnsRawTarget() + { + GameObject root = CreateGameObject("MouseUiTargetResolverTests_DropHandlerRoot"); + root.AddComponent(); + GameObject expectedTarget = CreateGameObject("DropTarget", root.transform); + string targetPath = GameObjectPathUtility.GetFullPath(expectedTarget); + MouseUiSimulationCommand command = CreateCommand(new SimulateMouseUiSchema + { + Action = UnityCliLoopMouseUiAction.Drag, + DropTargetPath = targetPath + }); + + bool resolved = SimulateMouseUiUseCase.TryResolveDropTargetPath( + command, + MouseAction.Drag, + new Vector2(10f, 20f), + out GameObject? dropTarget, + out SimulateMouseUiResponse? failureResponse); + + Assert.That(resolved, Is.True); + Assert.That(dropTarget, Is.SameAs(expectedTarget)); + Assert.That(failureResponse, Is.Null); + } + + /// + /// Verifies bypass resolution preserves raw and hierarchy handlers in pointer state and result DTO. + /// + [Test] + public void ResolvePressablePointerTargets_WithBypassPath_MapsHierarchyHandlers() + { + EventSystem eventSystem = CreateEventSystem(); + GameObject handlerTarget = CreateGameObject("MouseUiTargetResolverTests_PressHandler"); + handlerTarget.AddComponent(); + GameObject rawTarget = CreateGameObject("RawTarget", handlerTarget.transform); + string targetPath = GameObjectPathUtility.GetFullPath(rawTarget); + MouseUiSimulationCommand command = CreateCommand(new SimulateMouseUiSchema + { + Action = UnityCliLoopMouseUiAction.Click, + BypassRaycast = true, + TargetPath = targetPath + }); + Vector2 position = new(10f, 20f); + PointerEventData pointerData = + SimulateMouseUiUseCase.CreatePointerPressData(eventSystem, position, MouseButton.Left); + + SimulateMouseUiUseCase.ResolvedPointerTargets resolvedTargets = + SimulateMouseUiUseCase.ResolvePressablePointerTargets( + command, + eventSystem, + position, + position, + pointerData, + MouseAction.Click); + + Assert.That(resolvedTargets.RawTarget, Is.SameAs(rawTarget)); + Assert.That(resolvedTargets.PressTarget, Is.SameAs(handlerTarget)); + Assert.That(resolvedTargets.ClickTarget, Is.SameAs(handlerTarget)); + Assert.That(resolvedTargets.Target, Is.SameAs(handlerTarget)); + Assert.That(resolvedTargets.FailureResponse, Is.Null); + Assert.That(pointerData.pointerCurrentRaycast.gameObject, Is.SameAs(rawTarget)); + Assert.That(pointerData.pointerPressRaycast.gameObject, Is.SameAs(rawTarget)); + Assert.That(pointerData.pointerPress, Is.SameAs(handlerTarget)); + Assert.That(pointerData.rawPointerPress, Is.SameAs(rawTarget)); + } + + /// + /// Verifies non-bypass resolution returns an empty DTO when no raycaster reports a hit. + /// + [Test] + public void ResolvePressablePointerTargets_WithoutRaycastHit_ReturnsEmpty() + { + EventSystem eventSystem = CreateEventSystem(); + MouseUiSimulationCommand command = CreateCommand(new SimulateMouseUiSchema + { + Action = UnityCliLoopMouseUiAction.Click + }); + Vector2 position = new(10f, 20f); + PointerEventData pointerData = + SimulateMouseUiUseCase.CreatePointerPressData(eventSystem, position, MouseButton.Left); + + SimulateMouseUiUseCase.ResolvedPointerTargets resolvedTargets = + SimulateMouseUiUseCase.ResolvePressablePointerTargets( + command, + eventSystem, + position, + position, + pointerData, + MouseAction.Click); + + Assert.That(resolvedTargets.RawTarget, Is.Null); + Assert.That(resolvedTargets.PressTarget, Is.Null); + Assert.That(resolvedTargets.ClickTarget, Is.Null); + Assert.That(resolvedTargets.Target, Is.Null); + Assert.That(resolvedTargets.FailureResponse, Is.Null); + } + + /// + /// Verifies direct raycast construction carries the supplied GameObject. + /// + [Test] + public void CreateDirectRaycastResult_WithTarget_MapsGameObject() + { + GameObject target = CreateGameObject("MouseUiTargetResolverTests_DirectRaycastTarget"); + + RaycastResult result = SimulateMouseUiUseCase.CreateDirectRaycastResult(target); + + Assert.That(result.gameObject, Is.SameAs(target)); + } + + private EventSystem CreateEventSystem() + { + GameObject gameObject = CreateGameObject("MouseUiTargetResolverTests_EventSystem"); + return gameObject.AddComponent(); + } + + private GameObject CreateGameObject(string name, Transform? parent = null) + { + GameObject gameObject = new(name); + createdObjects.Add(gameObject); + if (parent != null) + { + gameObject.transform.SetParent(parent); + } + + return gameObject; + } + + private static MouseUiSimulationCommand CreateCommand(SimulateMouseUiSchema schema) + { + (MouseUiSimulationCommand? command, string? errorMessage) = + MouseUiSimulationCommand.TryFromSchema(schema); + Assert.That(errorMessage, Is.Null); + Assert.That(command, Is.Not.Null); + return command!; + } + } + + internal sealed class MouseUiPointerTargetResolverTestHandler : + MonoBehaviour, + IPointerDownHandler, + IPointerClickHandler, + IDropHandler + { + public void OnPointerDown(PointerEventData eventData) + { + } + + public void OnPointerClick(PointerEventData eventData) + { + } + + public void OnDrop(PointerEventData eventData) + { + } + } +} diff --git a/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs.meta b/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs.meta new file mode 100644 index 0000000000..3155526d9b --- /dev/null +++ b/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81d61bb5a1c24251a40a4de59570bebe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs index 0e2afe7b94..93bfb12bd0 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs @@ -324,7 +324,7 @@ private async Task ExecuteLongPress( return MouseUiSimulationResponseFactory.CreateLongPressResult(parameters, inputPos, targetName, hitTarget); } - private static PointerEventData CreatePointerPressData( + internal static PointerEventData CreatePointerPressData( EventSystem eventSystem, Vector2 screenPos, MouseButton button) @@ -337,7 +337,7 @@ private static PointerEventData CreatePointerPressData( }; } - private static ResolvedPointerTargets ResolvePressablePointerTargets( + internal static ResolvedPointerTargets ResolvePressablePointerTargets( MouseUiSimulationCommand parameters, EventSystem eventSystem, Vector2 inputPos, @@ -1133,7 +1133,7 @@ private void ExecuteCleanupOnMainThread(Action cleanup) return UiRaycastHelper.RaycastUI(screenPosition, eventSystem); } - private static bool TryResolveGameObjectPath( + internal static bool TryResolveGameObjectPath( string targetPath, string parameterName, MouseAction action, @@ -1164,7 +1164,7 @@ private static bool TryResolveGameObjectPath( return false; } - private static bool TryResolveDropTargetPath( + internal static bool TryResolveDropTargetPath( MouseUiSimulationCommand parameters, MouseAction action, Vector2 inputPosition, @@ -1239,7 +1239,7 @@ private static TargetPathLookupResult FindActiveGameObjectByPath(string targetPa : new TargetPathLookupResult(null, matchCount); } - private static RaycastResult CreateDirectRaycastResult(GameObject target) + internal static RaycastResult CreateDirectRaycastResult(GameObject target) { return new RaycastResult { @@ -1259,7 +1259,7 @@ public TargetPathLookupResult(GameObject? target, int matchCount) public int MatchCount { get; } } - private readonly struct ResolvedPointerTargets + internal readonly struct ResolvedPointerTargets { private ResolvedPointerTargets( GameObject? rawTarget, From 16de4dabd4c5fe17d69f340e322ea5ea7039fb0f Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 04:46:04 +0900 Subject: [PATCH 2/2] Extract mouse UI pointer target resolution Move pointer data construction, hierarchy and path lookup, drop target validation, and their result DTOs into a stateless resolver. Keep the existing out-parameter contract for move-only review, while removing the redundant RaycastUI wrapper in favor of direct UiRaycastHelper calls. --- .../MouseUiPointerTargetResolverTests.cs | 28 +- .../MouseUiPointerTargetResolver.cs | 241 ++++++++++++++ .../MouseUiPointerTargetResolver.cs.meta | 11 + .../SimulateMouseUi/ResolvedPointerTargets.cs | 50 +++ .../ResolvedPointerTargets.cs.meta | 11 + .../SimulateMouseUi/SimulateMouseUiUseCase.cs | 299 +----------------- 6 files changed, 340 insertions(+), 300 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPointerTargetResolver.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPointerTargetResolver.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateMouseUi/ResolvedPointerTargets.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateMouseUi/ResolvedPointerTargets.cs.meta diff --git a/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs b/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs index 71febacd6d..00e80320cc 100644 --- a/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs +++ b/Assets/Tests/Editor/MouseUiPointerTargetResolverTests.cs @@ -42,7 +42,7 @@ public void CreatePointerPressData_WithRightButton_MapsPressState() Vector2 screenPosition = new(12.5f, 34.5f); PointerEventData pointerData = - SimulateMouseUiUseCase.CreatePointerPressData(eventSystem, screenPosition, MouseButton.Right); + MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, screenPosition, MouseButton.Right); Assert.That(pointerData.position, Is.EqualTo(screenPosition)); Assert.That(pointerData.pressPosition, Is.EqualTo(screenPosition)); @@ -60,7 +60,7 @@ public void TryResolveGameObjectPath_WithUniquePath_ReturnsTarget() string targetPath = GameObjectPathUtility.GetFullPath(expectedTarget); Vector2 inputPosition = new(10f, 20f); - bool resolved = SimulateMouseUiUseCase.TryResolveGameObjectPath( + bool resolved = MouseUiPointerTargetResolver.TryResolveGameObjectPath( targetPath, "TargetPath", MouseAction.Click, @@ -82,7 +82,7 @@ public void TryResolveGameObjectPath_WithMissingPath_ReturnsNotFoundFailure() string targetPath = "MouseUiTargetResolverTests_MissingRoot/Target"; Vector2 inputPosition = new(10f, 20f); - bool resolved = SimulateMouseUiUseCase.TryResolveGameObjectPath( + bool resolved = MouseUiPointerTargetResolver.TryResolveGameObjectPath( targetPath, "TargetPath", MouseAction.Click, @@ -113,7 +113,7 @@ public void TryResolveGameObjectPath_WithDuplicatePath_ReturnsAmbiguousFailure() string targetPath = GameObjectPathUtility.GetFullPath(firstTarget); Vector2 inputPosition = new(10f, 20f); - bool resolved = SimulateMouseUiUseCase.TryResolveGameObjectPath( + bool resolved = MouseUiPointerTargetResolver.TryResolveGameObjectPath( targetPath, "TargetPath", MouseAction.Click, @@ -142,7 +142,7 @@ public void TryResolveDropTargetPath_WithoutPath_ReturnsNoTarget() DropTargetPath = " " }); - bool resolved = SimulateMouseUiUseCase.TryResolveDropTargetPath( + bool resolved = MouseUiPointerTargetResolver.TryResolveDropTargetPath( command, MouseAction.Drag, new Vector2(10f, 20f), @@ -170,7 +170,7 @@ public void TryResolveDropTargetPath_WithoutDropHandler_ReturnsFailure() }); Vector2 inputPosition = new(10f, 20f); - bool resolved = SimulateMouseUiUseCase.TryResolveDropTargetPath( + bool resolved = MouseUiPointerTargetResolver.TryResolveDropTargetPath( command, MouseAction.Drag, inputPosition, @@ -204,7 +204,7 @@ public void TryResolveDropTargetPath_WithDropHandler_ReturnsRawTarget() DropTargetPath = targetPath }); - bool resolved = SimulateMouseUiUseCase.TryResolveDropTargetPath( + bool resolved = MouseUiPointerTargetResolver.TryResolveDropTargetPath( command, MouseAction.Drag, new Vector2(10f, 20f), @@ -235,10 +235,10 @@ public void ResolvePressablePointerTargets_WithBypassPath_MapsHierarchyHandlers( }); Vector2 position = new(10f, 20f); PointerEventData pointerData = - SimulateMouseUiUseCase.CreatePointerPressData(eventSystem, position, MouseButton.Left); + MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, position, MouseButton.Left); - SimulateMouseUiUseCase.ResolvedPointerTargets resolvedTargets = - SimulateMouseUiUseCase.ResolvePressablePointerTargets( + ResolvedPointerTargets resolvedTargets = + MouseUiPointerTargetResolver.ResolvePressablePointerTargets( command, eventSystem, position, @@ -270,10 +270,10 @@ public void ResolvePressablePointerTargets_WithoutRaycastHit_ReturnsEmpty() }); Vector2 position = new(10f, 20f); PointerEventData pointerData = - SimulateMouseUiUseCase.CreatePointerPressData(eventSystem, position, MouseButton.Left); + MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, position, MouseButton.Left); - SimulateMouseUiUseCase.ResolvedPointerTargets resolvedTargets = - SimulateMouseUiUseCase.ResolvePressablePointerTargets( + ResolvedPointerTargets resolvedTargets = + MouseUiPointerTargetResolver.ResolvePressablePointerTargets( command, eventSystem, position, @@ -296,7 +296,7 @@ public void CreateDirectRaycastResult_WithTarget_MapsGameObject() { GameObject target = CreateGameObject("MouseUiTargetResolverTests_DirectRaycastTarget"); - RaycastResult result = SimulateMouseUiUseCase.CreateDirectRaycastResult(target); + RaycastResult result = MouseUiPointerTargetResolver.CreateDirectRaycastResult(target); Assert.That(result.gameObject, Is.SameAs(target)); } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPointerTargetResolver.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPointerTargetResolver.cs new file mode 100644 index 0000000000..f2264cc4e0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPointerTargetResolver.cs @@ -0,0 +1,241 @@ +#nullable enable +using System; +using UnityEngine; +using UnityEngine.EventSystems; + +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Resolves pointer, hierarchy path, and drop targets for mouse UI simulation. + /// + internal static class MouseUiPointerTargetResolver + { + private static PointerEventData.InputButton ToInputButton(MouseButton button) + { + switch (button) + { + case MouseButton.Right: + return PointerEventData.InputButton.Right; + case MouseButton.Middle: + return PointerEventData.InputButton.Middle; + default: + return PointerEventData.InputButton.Left; + } + } + + internal static PointerEventData CreatePointerPressData( + EventSystem eventSystem, + Vector2 screenPos, + MouseButton button) + { + return new PointerEventData(eventSystem) + { + position = screenPos, + pressPosition = screenPos, + button = ToInputButton(button) + }; + } + + internal static ResolvedPointerTargets ResolvePressablePointerTargets( + MouseUiSimulationCommand parameters, + EventSystem eventSystem, + Vector2 inputPos, + Vector2 screenPos, + PointerEventData pointerData, + MouseAction action) + { + if (parameters.BypassRaycast) + { + return ResolveBypassPressablePointerTargets(parameters, inputPos, pointerData, action); + } + + RaycastResult? hit = UiRaycastHelper.RaycastUI(screenPos, eventSystem); + if (hit == null) + { + return ResolvedPointerTargets.Empty; + } + + return ResolveRaycastPressablePointerTargets(hit.Value, pointerData); + } + + private static ResolvedPointerTargets ResolveBypassPressablePointerTargets( + MouseUiSimulationCommand parameters, + Vector2 inputPos, + PointerEventData pointerData, + MouseAction action) + { + if (!TryResolveGameObjectPath( + parameters.TargetPath, + "TargetPath", + action, + inputPos, + out GameObject? rawTarget, + out SimulateMouseUiResponse? failureResponse)) + { + return ResolvedPointerTargets.Failure(failureResponse); + } + + RaycastResult directRaycast = CreateDirectRaycastResult(rawTarget!); + pointerData.pointerCurrentRaycast = directRaycast; + pointerData.pointerPressRaycast = directRaycast; + + return CreateResolvedPressablePointerTargets(rawTarget!, pointerData); + } + + private static ResolvedPointerTargets ResolveRaycastPressablePointerTargets( + RaycastResult hit, + PointerEventData pointerData) + { + GameObject rawTarget = hit.gameObject; + pointerData.pointerCurrentRaycast = hit; + pointerData.pointerPressRaycast = hit; + + return CreateResolvedPressablePointerTargets(rawTarget, pointerData); + } + + private static ResolvedPointerTargets CreateResolvedPressablePointerTargets( + GameObject rawTarget, + PointerEventData pointerData) + { + // Execute dispatches only to the exact target; composite controls need hierarchy traversal. + GameObject? pressTarget = ExecuteEvents.GetEventHandler(rawTarget); + GameObject? clickTarget = ExecuteEvents.GetEventHandler(rawTarget); + GameObject? target = pressTarget ?? clickTarget; + if (target != null) + { + pointerData.pointerPress = target; + pointerData.rawPointerPress = rawTarget; + } + + return ResolvedPointerTargets.Success(rawTarget, pressTarget, clickTarget, target); + } + + internal static bool TryResolveGameObjectPath( + string targetPath, + string parameterName, + MouseAction action, + Vector2 inputPosition, + out GameObject? target, + out SimulateMouseUiResponse? failureResponse) + { + TargetPathLookupResult lookupResult = FindActiveGameObjectByPath(targetPath); + target = lookupResult.Target; + if (target != null) + { + failureResponse = null; + return true; + } + + string message = lookupResult.MatchCount == 0 + ? $"{parameterName} '{targetPath}' was not found." + : $"{parameterName} '{targetPath}' matched {lookupResult.MatchCount} active GameObjects. Use a unique hierarchy path."; + + failureResponse = new SimulateMouseUiResponse + { + Success = false, + Message = message, + Action = action.ToString(), + PositionX = inputPosition.x, + PositionY = inputPosition.y + }; + return false; + } + + internal static bool TryResolveDropTargetPath( + MouseUiSimulationCommand parameters, + MouseAction action, + Vector2 inputPosition, + out GameObject? dropTarget, + out SimulateMouseUiResponse? failureResponse) + { + dropTarget = null; + failureResponse = null; + + if (string.IsNullOrWhiteSpace(parameters.DropTargetPath)) + { + return true; + } + + if (!TryResolveGameObjectPath( + parameters.DropTargetPath, + "DropTargetPath", + action, + inputPosition, + out GameObject? rawDropTarget, + out failureResponse)) + { + return false; + } + + GameObject? dropHandler = ExecuteEvents.GetEventHandler(rawDropTarget!); + if (dropHandler == null) + { + failureResponse = new SimulateMouseUiResponse + { + Success = false, + Message = $"DropTargetPath '{parameters.DropTargetPath}' has no drop handler.", + Action = action.ToString(), + PositionX = inputPosition.x, + PositionY = inputPosition.y + }; + return false; + } + + dropTarget = rawDropTarget; + return true; + } + + private static TargetPathLookupResult FindActiveGameObjectByPath(string targetPath) + { + string normalizedPath = targetPath.Trim().Trim('/'); + if (string.IsNullOrEmpty(normalizedPath)) + { + return new TargetPathLookupResult(null, 0); + } + + GameObject[] gameObjects = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); + GameObject? matchedTarget = null; + int matchCount = 0; + + foreach (GameObject gameObject in gameObjects) + { + if (!string.Equals( + GameObjectPathUtility.GetFullPath(gameObject), + normalizedPath, + StringComparison.Ordinal)) + { + continue; + } + + matchCount++; + matchedTarget = gameObject; + } + + return matchCount == 1 + ? new TargetPathLookupResult(matchedTarget, matchCount) + : new TargetPathLookupResult(null, matchCount); + } + + internal static RaycastResult CreateDirectRaycastResult(GameObject target) + { + return new RaycastResult + { + gameObject = target + }; + } + + private readonly struct TargetPathLookupResult + { + public TargetPathLookupResult(GameObject? target, int matchCount) + { + Target = target; + MatchCount = matchCount; + } + + public GameObject? Target { get; } + public int MatchCount { get; } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPointerTargetResolver.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPointerTargetResolver.cs.meta new file mode 100644 index 0000000000..ea90b84ded --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPointerTargetResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 49fa14da50334771a724084c34c05d5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/ResolvedPointerTargets.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/ResolvedPointerTargets.cs new file mode 100644 index 0000000000..f823b39d00 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/ResolvedPointerTargets.cs @@ -0,0 +1,50 @@ +#nullable enable +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Carries raw and hierarchy-resolved pointer targets or a resolution failure. + /// + internal readonly struct ResolvedPointerTargets + { + private ResolvedPointerTargets( + GameObject? rawTarget, + GameObject? pressTarget, + GameObject? clickTarget, + GameObject? target, + SimulateMouseUiResponse? failureResponse) + { + RawTarget = rawTarget; + PressTarget = pressTarget; + ClickTarget = clickTarget; + Target = target; + FailureResponse = failureResponse; + } + + public static ResolvedPointerTargets Empty { get; } = + new(null, null, null, null, null); + + public GameObject? RawTarget { get; } + public GameObject? PressTarget { get; } + public GameObject? ClickTarget { get; } + public GameObject? Target { get; } + public SimulateMouseUiResponse? FailureResponse { get; } + + public static ResolvedPointerTargets Success( + GameObject rawTarget, + GameObject? pressTarget, + GameObject? clickTarget, + GameObject? target) + { + return new ResolvedPointerTargets(rawTarget, pressTarget, clickTarget, target, null); + } + + public static ResolvedPointerTargets Failure( + SimulateMouseUiResponse? failureResponse) + { + Debug.Assert(failureResponse != null, "Failure response must exist when target resolution fails."); + return new ResolvedPointerTargets(null, null, null, null, failureResponse); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/ResolvedPointerTargets.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/ResolvedPointerTargets.cs.meta new file mode 100644 index 0000000000..4520f4e776 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/ResolvedPointerTargets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 301801bfefc74357aecb54bea115374c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs index 93bfb12bd0..debd1d01c1 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs @@ -151,19 +151,6 @@ private static void EnsureOverlayExists() OverlayCanvasFactory.EnsureExists(); } - private static PointerEventData.InputButton ToInputButton(MouseButton button) - { - switch (button) - { - case MouseButton.Right: - return PointerEventData.InputButton.Right; - case MouseButton.Middle: - return PointerEventData.InputButton.Middle; - default: - return PointerEventData.InputButton.Left; - } - } - // Input coordinates use top-left origin; Unity Screen space uses bottom-left origin. // Handles.GetMainGameViewSize() returns the Game view's target resolution (e.g. 1920x1080), // which matches the Canvas layout space — unlike Screen.height which returns the window pixel size. @@ -184,9 +171,9 @@ private async Task ExecuteClick( { Vector2 inputPos = new(parameters.X, parameters.Y); Vector2 screenPos = InputToScreen(inputPos); - PointerEventData pointerData = CreatePointerPressData(eventSystem, screenPos, parameters.Button); + PointerEventData pointerData = MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, screenPos, parameters.Button); ResolvedPointerTargets resolvedTargets = - ResolvePressablePointerTargets(parameters, eventSystem, inputPos, screenPos, pointerData, MouseAction.Click); + MouseUiPointerTargetResolver.ResolvePressablePointerTargets(parameters, eventSystem, inputPos, screenPos, pointerData, MouseAction.Click); if (resolvedTargets.FailureResponse != null) { return resolvedTargets.FailureResponse; @@ -247,9 +234,9 @@ private async Task ExecuteLongPress( Vector2 inputPos = new(parameters.X, parameters.Y); Vector2 screenPos = InputToScreen(inputPos); - PointerEventData pointerData = CreatePointerPressData(eventSystem, screenPos, parameters.Button); + PointerEventData pointerData = MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, screenPos, parameters.Button); ResolvedPointerTargets resolvedTargets = - ResolvePressablePointerTargets(parameters, eventSystem, inputPos, screenPos, pointerData, MouseAction.LongPress); + MouseUiPointerTargetResolver.ResolvePressablePointerTargets(parameters, eventSystem, inputPos, screenPos, pointerData, MouseAction.LongPress); if (resolvedTargets.FailureResponse != null) { return resolvedTargets.FailureResponse; @@ -324,93 +311,6 @@ private async Task ExecuteLongPress( return MouseUiSimulationResponseFactory.CreateLongPressResult(parameters, inputPos, targetName, hitTarget); } - internal static PointerEventData CreatePointerPressData( - EventSystem eventSystem, - Vector2 screenPos, - MouseButton button) - { - return new PointerEventData(eventSystem) - { - position = screenPos, - pressPosition = screenPos, - button = ToInputButton(button) - }; - } - - internal static ResolvedPointerTargets ResolvePressablePointerTargets( - MouseUiSimulationCommand parameters, - EventSystem eventSystem, - Vector2 inputPos, - Vector2 screenPos, - PointerEventData pointerData, - MouseAction action) - { - if (parameters.BypassRaycast) - { - return ResolveBypassPressablePointerTargets(parameters, inputPos, pointerData, action); - } - - RaycastResult? hit = RaycastUI(screenPos, eventSystem); - if (hit == null) - { - return ResolvedPointerTargets.Empty; - } - - return ResolveRaycastPressablePointerTargets(hit.Value, pointerData); - } - - private static ResolvedPointerTargets ResolveBypassPressablePointerTargets( - MouseUiSimulationCommand parameters, - Vector2 inputPos, - PointerEventData pointerData, - MouseAction action) - { - if (!TryResolveGameObjectPath( - parameters.TargetPath, - "TargetPath", - action, - inputPos, - out GameObject? rawTarget, - out SimulateMouseUiResponse? failureResponse)) - { - return ResolvedPointerTargets.Failure(failureResponse); - } - - RaycastResult directRaycast = CreateDirectRaycastResult(rawTarget!); - pointerData.pointerCurrentRaycast = directRaycast; - pointerData.pointerPressRaycast = directRaycast; - - return CreateResolvedPressablePointerTargets(rawTarget!, pointerData); - } - - private static ResolvedPointerTargets ResolveRaycastPressablePointerTargets( - RaycastResult hit, - PointerEventData pointerData) - { - GameObject rawTarget = hit.gameObject; - pointerData.pointerCurrentRaycast = hit; - pointerData.pointerPressRaycast = hit; - - return CreateResolvedPressablePointerTargets(rawTarget, pointerData); - } - - private static ResolvedPointerTargets CreateResolvedPressablePointerTargets( - GameObject rawTarget, - PointerEventData pointerData) - { - // Execute dispatches only to the exact target; composite controls need hierarchy traversal. - GameObject? pressTarget = ExecuteEvents.GetEventHandler(rawTarget); - GameObject? clickTarget = ExecuteEvents.GetEventHandler(rawTarget); - GameObject? target = pressTarget ?? clickTarget; - if (target != null) - { - pointerData.pointerPress = target; - pointerData.rawPointerPress = rawTarget; - } - - return ResolvedPointerTargets.Success(rawTarget, pressTarget, clickTarget, target); - } - private static void ExecutePointerClickEvents( ResolvedPointerTargets resolvedTargets, PointerEventData pointerData) @@ -496,13 +396,13 @@ private async Task ExecuteDragOneShot( Vector2 inputEnd = new(parameters.X, parameters.Y); Vector2 screenStart = InputToScreen(inputStart); Vector2 screenEnd = InputToScreen(inputEnd); - RaycastResult? hit = parameters.BypassRaycast ? null : RaycastUI(screenStart, eventSystem); + RaycastResult? hit = parameters.BypassRaycast ? null : UiRaycastHelper.RaycastUI(screenStart, eventSystem); RaycastResult startRaycast = new(); GameObject? rawTarget = null; if (parameters.BypassRaycast) { - if (!TryResolveGameObjectPath( + if (!MouseUiPointerTargetResolver.TryResolveGameObjectPath( parameters.TargetPath, "TargetPath", MouseAction.Drag, @@ -513,7 +413,7 @@ private async Task ExecuteDragOneShot( return failureResponse!; } - startRaycast = CreateDirectRaycastResult(rawTarget!); + startRaycast = MouseUiPointerTargetResolver.CreateDirectRaycastResult(rawTarget!); } else if (hit != null) { @@ -522,7 +422,7 @@ private async Task ExecuteDragOneShot( } GameObject? explicitDropTarget = null; - if (!TryResolveDropTargetPath( + if (!MouseUiPointerTargetResolver.TryResolveDropTargetPath( parameters, MouseAction.Drag, inputEnd, @@ -631,7 +531,7 @@ private void FinalizeDrag(PointerEventData pointerData, GameObject target, GameO { if (explicitDropTarget != null) { - pointerData.pointerCurrentRaycast = CreateDirectRaycastResult(explicitDropTarget); + pointerData.pointerCurrentRaycast = MouseUiPointerTargetResolver.CreateDirectRaycastResult(explicitDropTarget); } else { @@ -737,13 +637,13 @@ private async Task ExecuteDragStart( Vector2 inputPos = new(parameters.X, parameters.Y); Vector2 screenPos = InputToScreen(inputPos); - RaycastResult? hit = parameters.BypassRaycast ? null : RaycastUI(screenPos, eventSystem); + RaycastResult? hit = parameters.BypassRaycast ? null : UiRaycastHelper.RaycastUI(screenPos, eventSystem); RaycastResult startRaycast = new(); GameObject? rawTarget = null; if (parameters.BypassRaycast) { - if (!TryResolveGameObjectPath( + if (!MouseUiPointerTargetResolver.TryResolveGameObjectPath( parameters.TargetPath, "TargetPath", MouseAction.DragStart, @@ -754,7 +654,7 @@ private async Task ExecuteDragStart( return failureResponse!; } - startRaycast = CreateDirectRaycastResult(rawTarget!); + startRaycast = MouseUiPointerTargetResolver.CreateDirectRaycastResult(rawTarget!); } else if (hit != null) { @@ -936,7 +836,7 @@ private async Task ExecuteDragEnd( string targetName = target.name; GameObject? explicitDropTarget = null; - if (!TryResolveDropTargetPath( + if (!MouseUiPointerTargetResolver.TryResolveDropTargetPath( parameters, MouseAction.DragEnd, inputEnd, @@ -1128,178 +1028,5 @@ private void ExecuteCleanupOnMainThread(Action cleanup) context.Post(_ => cleanup(), null); } - private static RaycastResult? RaycastUI(Vector2 screenPosition, EventSystem eventSystem) - { - return UiRaycastHelper.RaycastUI(screenPosition, eventSystem); - } - - internal static bool TryResolveGameObjectPath( - string targetPath, - string parameterName, - MouseAction action, - Vector2 inputPosition, - out GameObject? target, - out SimulateMouseUiResponse? failureResponse) - { - TargetPathLookupResult lookupResult = FindActiveGameObjectByPath(targetPath); - target = lookupResult.Target; - if (target != null) - { - failureResponse = null; - return true; - } - - string message = lookupResult.MatchCount == 0 - ? $"{parameterName} '{targetPath}' was not found." - : $"{parameterName} '{targetPath}' matched {lookupResult.MatchCount} active GameObjects. Use a unique hierarchy path."; - - failureResponse = new SimulateMouseUiResponse - { - Success = false, - Message = message, - Action = action.ToString(), - PositionX = inputPosition.x, - PositionY = inputPosition.y - }; - return false; - } - - internal static bool TryResolveDropTargetPath( - MouseUiSimulationCommand parameters, - MouseAction action, - Vector2 inputPosition, - out GameObject? dropTarget, - out SimulateMouseUiResponse? failureResponse) - { - dropTarget = null; - failureResponse = null; - - if (string.IsNullOrWhiteSpace(parameters.DropTargetPath)) - { - return true; - } - - if (!TryResolveGameObjectPath( - parameters.DropTargetPath, - "DropTargetPath", - action, - inputPosition, - out GameObject? rawDropTarget, - out failureResponse)) - { - return false; - } - - GameObject? dropHandler = ExecuteEvents.GetEventHandler(rawDropTarget!); - if (dropHandler == null) - { - failureResponse = new SimulateMouseUiResponse - { - Success = false, - Message = $"DropTargetPath '{parameters.DropTargetPath}' has no drop handler.", - Action = action.ToString(), - PositionX = inputPosition.x, - PositionY = inputPosition.y - }; - return false; - } - - dropTarget = rawDropTarget; - return true; - } - - private static TargetPathLookupResult FindActiveGameObjectByPath(string targetPath) - { - string normalizedPath = targetPath.Trim().Trim('/'); - if (string.IsNullOrEmpty(normalizedPath)) - { - return new TargetPathLookupResult(null, 0); - } - - GameObject[] gameObjects = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); - GameObject? matchedTarget = null; - int matchCount = 0; - - foreach (GameObject gameObject in gameObjects) - { - if (!string.Equals( - GameObjectPathUtility.GetFullPath(gameObject), - normalizedPath, - StringComparison.Ordinal)) - { - continue; - } - - matchCount++; - matchedTarget = gameObject; - } - - return matchCount == 1 - ? new TargetPathLookupResult(matchedTarget, matchCount) - : new TargetPathLookupResult(null, matchCount); - } - - internal static RaycastResult CreateDirectRaycastResult(GameObject target) - { - return new RaycastResult - { - gameObject = target - }; - } - - private readonly struct TargetPathLookupResult - { - public TargetPathLookupResult(GameObject? target, int matchCount) - { - Target = target; - MatchCount = matchCount; - } - - public GameObject? Target { get; } - public int MatchCount { get; } - } - - internal readonly struct ResolvedPointerTargets - { - private ResolvedPointerTargets( - GameObject? rawTarget, - GameObject? pressTarget, - GameObject? clickTarget, - GameObject? target, - SimulateMouseUiResponse? failureResponse) - { - RawTarget = rawTarget; - PressTarget = pressTarget; - ClickTarget = clickTarget; - Target = target; - FailureResponse = failureResponse; - } - - public static ResolvedPointerTargets Empty { get; } = - new(null, null, null, null, null); - - public GameObject? RawTarget { get; } - public GameObject? PressTarget { get; } - public GameObject? ClickTarget { get; } - public GameObject? Target { get; } - public SimulateMouseUiResponse? FailureResponse { get; } - - public static ResolvedPointerTargets Success( - GameObject rawTarget, - GameObject? pressTarget, - GameObject? clickTarget, - GameObject? target) - { - return new ResolvedPointerTargets(rawTarget, pressTarget, clickTarget, target, null); - } - - public static ResolvedPointerTargets Failure( - SimulateMouseUiResponse? failureResponse) - { - Debug.Assert(failureResponse != null, "Failure response must exist when target resolution fails."); - return new ResolvedPointerTargets(null, null, null, null, failureResponse); - } - } - } }