Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Assets/Tests/Editor/MouseUiCoordinateConverterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using NUnit.Framework;
using UnityEditor;
using UnityEngine;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Characterizes coordinate conversion shared by mouse UI action executors.
/// </summary>
[TestFixture]
public sealed class MouseUiCoordinateConverterTests
{
/// <summary>
/// Verifies top-left input coordinates are flipped against the current Game view height.
/// </summary>
[Test]
public void InputToScreen_WithTopLeftCoordinates_FlipsYAgainstCurrentGameViewHeight()
{
Vector2 inputPosition = new(12.5f, 37.25f);
float gameViewHeight = Handles.GetMainGameViewSize().y;

Vector2 screenPosition = MouseUiCoordinateConverter.InputToScreen(inputPosition);

Assert.That(
screenPosition,
Is.EqualTo(new Vector2(inputPosition.x, gameViewHeight - inputPosition.y)));
}

/// <summary>
/// Verifies converting input coordinates to screen space and back preserves the position.
/// </summary>
[Test]
public void ScreenToInput_AfterInputToScreen_RestoresPosition()
{
Vector2 inputPosition = new(42.5f, 64.25f);

Vector2 screenPosition = MouseUiCoordinateConverter.InputToScreen(inputPosition);
Vector2 restoredPosition = MouseUiCoordinateConverter.ScreenToInput(screenPosition);

Assert.That(restoredPosition, Is.EqualTo(inputPosition));
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/MouseUiCoordinateConverterTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions Assets/Tests/Editor/StaticFacadeStateGuardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,11 @@ public void ExecuteDynamicCodeUseCase_WhenAwaitingWorkflow_DoesNotCaptureEditorS
}

[Test]
public void SimulateMouseUiUseCase_WhenReturningAfterTimeout_UsesCapturedUnityObjectState()
public void MouseUiPressActionExecutor_WhenReturningAfterTimeout_UsesCapturedUnityObjectState()
{
// Tests that timeout result branches use plain captured bools instead of UnityEngine.Object null checks.
string source = ReadSourceFile("Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs");
string source = ReadSourceFile(
"Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs");

Assert.That(source, Does.Contain("bool hitTarget = resolvedTargets.Target != null;"));
Assert.That(source, Does.Contain("bool shouldReleasePointer = resolvedTargets.RawTarget != null && resolvedTargets.Target != null;"));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#nullable enable
using UnityEditor;
using UnityEngine;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Converts between top-left mouse input coordinates and Unity screen coordinates.
/// </summary>
internal static class MouseUiCoordinateConverter
{
// 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.
internal static Vector2 InputToScreen(Vector2 inputPos)
{
float targetHeight = Handles.GetMainGameViewSize().y;
return new Vector2(inputPos.x, targetHeight - inputPos.y);
}

internal static Vector2 ScreenToInput(Vector2 screenPos)
{
float targetHeight = Handles.GetMainGameViewSize().y;
return new Vector2(screenPos.x, targetHeight - screenPos.y);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
#nullable enable
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;

using io.github.hatayama.UnityCliLoop.Runtime;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Executes click and long-press mouse UI actions.
/// </summary>
internal static class MouseUiPressActionExecutor
{
internal static async Task<SimulateMouseUiResponse> ExecuteClick(
MouseUiSimulationCommand parameters,
EventSystem eventSystem,
MouseUiMainThreadCleanupScheduler cleanupScheduler,
CancellationToken ct)
{
Vector2 inputPos = new(parameters.X, parameters.Y);
Vector2 screenPos = MouseUiCoordinateConverter.InputToScreen(inputPos);
PointerEventData pointerData = MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, screenPos, parameters.Button);
ResolvedPointerTargets resolvedTargets =
MouseUiPointerTargetResolver.ResolvePressablePointerTargets(parameters, eventSystem, inputPos, screenPos, pointerData, MouseAction.Click);
if (resolvedTargets.FailureResponse != null)
{
return resolvedTargets.FailureResponse;
}

if (parameters.BypassRaycast && resolvedTargets.Target == null)
{
return new SimulateMouseUiResponse
{
Success = false,
Message = $"TargetPath '{parameters.TargetPath}' has no pointer click or pointer down handler.",
Action = MouseAction.Click.ToString(),
PositionX = inputPos.x,
PositionY = inputPos.y
};
}

string? targetName = resolvedTargets.Target?.name;
bool hitTarget = resolvedTargets.Target != null;
SimulateMouseUiOverlayState.Update(
MouseAction.Click, inputPos, null,
targetName, Handles.GetMainGameViewSize());

bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false);
if (!expandCompleted)
{
cleanupScheduler.QueueOverlayClear();
return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Click, inputPos, null, targetName);
}
await MainThreadSwitcher.SwitchToMainThread(ct);

// Fire click events after expand animation so the user sees where the click lands
ExecutePointerClickEvents(resolvedTargets, pointerData);

bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false);
if (!dissipateCompleted)
{
cleanupScheduler.QueueOverlayClear();
return MouseUiSimulationResponseFactory.CreateClickResult(parameters, inputPos, targetName, hitTarget);
}
await MainThreadSwitcher.SwitchToMainThread(ct);

return MouseUiSimulationResponseFactory.CreateClickResult(parameters, inputPos, targetName, hitTarget);
}

internal static async Task<SimulateMouseUiResponse> ExecuteLongPress(
MouseUiSimulationCommand parameters,
EventSystem eventSystem,
MouseUiMainThreadCleanupScheduler cleanupScheduler,
CancellationToken ct)
{
if (parameters.Duration <= 0f || float.IsNaN(parameters.Duration) || float.IsInfinity(parameters.Duration))
{
return new SimulateMouseUiResponse
{
Success = false,
Message = $"Duration must be positive, got: {parameters.Duration}",
Action = MouseAction.LongPress.ToString()
};
}

Vector2 inputPos = new(parameters.X, parameters.Y);
Vector2 screenPos = MouseUiCoordinateConverter.InputToScreen(inputPos);
PointerEventData pointerData = MouseUiPointerTargetResolver.CreatePointerPressData(eventSystem, screenPos, parameters.Button);
ResolvedPointerTargets resolvedTargets =
MouseUiPointerTargetResolver.ResolvePressablePointerTargets(parameters, eventSystem, inputPos, screenPos, pointerData, MouseAction.LongPress);
if (resolvedTargets.FailureResponse != null)
{
return resolvedTargets.FailureResponse;
}

if (parameters.BypassRaycast && resolvedTargets.Target == null)
{
return new SimulateMouseUiResponse
{
Success = false,
Message = $"TargetPath '{parameters.TargetPath}' has no pointer down or pointer click handler.",
Action = MouseAction.LongPress.ToString(),
PositionX = inputPos.x,
PositionY = inputPos.y
};
}

string? targetName = resolvedTargets.Target?.name;
bool hitTarget = resolvedTargets.Target != null;
bool shouldReleasePointer = resolvedTargets.RawTarget != null && resolvedTargets.Target != null;
SimulateMouseUiOverlayState.Update(
MouseAction.LongPress, inputPos, null,
targetName, Handles.GetMainGameViewSize());

bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false);
if (!expandCompleted)
{
cleanupScheduler.QueueOverlayClear();
return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.LongPress, inputPos, null, targetName);
}
await MainThreadSwitcher.SwitchToMainThread(ct);

ExecuteLongPressPointerDown(resolvedTargets, pointerData);

try
{
// Hold for Duration seconds, updating elapsed time each frame for overlay display
float startTime = Time.realtimeSinceStartup;
float elapsed = 0f;
while (elapsed < parameters.Duration)
{
SimulateMouseUiOverlayState.UpdateLongPressElapsed(elapsed);
bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false);
if (!frameReady)
{
cleanupScheduler.QueueOverlayClear();
return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.LongPress, inputPos, null, targetName);
}
await MainThreadSwitcher.SwitchToMainThread(ct);
elapsed = Time.realtimeSinceStartup - startTime;
}
SimulateMouseUiOverlayState.UpdateLongPressElapsed(parameters.Duration);
}
finally
{
// Ensure pointerUp fires even if the hold loop is cancelled
if (shouldReleasePointer)
{
cleanupScheduler.ExecuteCleanupOnMainThread(
() => ExecuteEvents.Execute(resolvedTargets.Target!, pointerData, ExecuteEvents.pointerUpHandler));
}
}

bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false);
if (!dissipateCompleted)
{
cleanupScheduler.QueueOverlayClear();
return MouseUiSimulationResponseFactory.CreateLongPressResult(parameters, inputPos, targetName, hitTarget);
}
await MainThreadSwitcher.SwitchToMainThread(ct);

return MouseUiSimulationResponseFactory.CreateLongPressResult(parameters, inputPos, targetName, hitTarget);
}

private static void ExecutePointerClickEvents(
ResolvedPointerTargets resolvedTargets,
PointerEventData pointerData)
{
if (resolvedTargets.RawTarget == null)
{
return;
}

if (resolvedTargets.PressTarget != null)
{
ExecuteEvents.ExecuteHierarchy(
resolvedTargets.RawTarget,
pointerData,
ExecuteEvents.pointerDownHandler);
}

if (resolvedTargets.Target != null)
{
ExecuteEvents.Execute(
resolvedTargets.Target,
pointerData,
ExecuteEvents.pointerUpHandler);
}

if (resolvedTargets.ClickTarget != null)
{
ExecuteEvents.Execute(
resolvedTargets.ClickTarget,
pointerData,
ExecuteEvents.pointerClickHandler);
}
}

private static void ExecuteLongPressPointerDown(
ResolvedPointerTargets resolvedTargets,
PointerEventData pointerData)
{
if (resolvedTargets.RawTarget == null || resolvedTargets.Target == null)
{
return;
}

ExecuteEvents.ExecuteHierarchy(
resolvedTargets.RawTarget,
pointerData,
ExecuteEvents.pointerDownHandler);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading