diff --git a/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs b/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs index 4526b8987e..1056bb1290 100644 --- a/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs +++ b/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs @@ -62,6 +62,7 @@ public sealed class StaticFacadeStateGuardTests "Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs", "Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs", "Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs", + "Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs", "Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs" }; diff --git a/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs b/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs new file mode 100644 index 0000000000..16f7102ec7 --- /dev/null +++ b/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs @@ -0,0 +1,110 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System; +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode +{ + /// + /// Characterizes wire-visible mouse input response construction before factory extraction. + /// + [TestFixture] + public sealed class MouseInputSimulationResponseFactoryTests + { + private readonly DateTime _nowUtc = new(2026, 7, 9, 0, 0, 0, DateTimeKind.Utc); + + [SetUp] + public void SetUp() + { + UloopPausePointRegistry.ConfigureForTests(new FakePauseController(), () => _nowUtc); + } + + [TearDown] + public void TearDown() + { + UloopPausePointRegistry.ResetForTests(); + } + + /// + /// Verifies interrupted button responses project every Pause Point hit and preserve button coordinates. + /// + [Test] + public void InterruptedButtonResult_WithMultiplePausePointHits_MapsPauseEvidenceAndButtonPosition() + { + UloopPausePointRegistry.Enable("first-hit", 30); + UloopPausePointRegistry.Enable("latest-hit", 30); + UloopPausePoint.Pause("first-hit"); + UloopPausePoint.Pause("latest-hit"); + Vector2 inputPosition = new(10.25f, 20.75f); + + SimulateMouseInputResponse response = MouseInputSimulationResponseFactory.InterruptedButtonResult( + UnityCliLoopMouseInputAction.Click, + "Left", + inputPosition); + + Assert.That(response.Success, Is.True); + Assert.That( + response.Message, + Is.EqualTo( + "Mouse input stopped because Unity paused during Pause Point inspection. Unity CLI Loop released its held input bookkeeping.")); + Assert.That(response.Action, Is.EqualTo(UnityCliLoopMouseInputAction.Click.ToString())); + Assert.That(response.Button, Is.EqualTo("Left")); + Assert.That(response.PositionX, Is.EqualTo(inputPosition.x)); + Assert.That(response.PositionY, Is.EqualTo(inputPosition.y)); + Assert.That(response.InterruptedByPausePoint, Is.True); + Assert.That(response.PausePointId, Is.EqualTo("latest-hit")); + Assert.That(response.PausePointHitCount, Is.EqualTo(1)); + Assert.That(response.PausePointHits, Has.Count.EqualTo(2)); + Assert.That(response.PausePointHits![0].Id, Is.EqualTo("first-hit")); + Assert.That(response.PausePointHits[1].Id, Is.EqualTo("latest-hit")); + } + + /// + /// Verifies timed-out button responses preserve the action, button, coordinates, and timeout message. + /// + [Test] + public void TimedOutButtonResult_WithButtonAndPosition_MapsFailureResponse() + { + Vector2 inputPosition = new(30.5f, 40.25f); + + SimulateMouseInputResponse response = MouseInputSimulationResponseFactory.TimedOutButtonResult( + UnityCliLoopMouseInputAction.LongPress, + "Right", + inputPosition); + + Assert.That(response.Success, Is.False); + Assert.That( + response.Message, + Is.EqualTo( + "Mouse input timed out while waiting for Unity Editor update. Cleanup is queued for the next Editor tick.")); + Assert.That(response.Action, Is.EqualTo(UnityCliLoopMouseInputAction.LongPress.ToString())); + Assert.That(response.Button, Is.EqualTo("Right")); + Assert.That(response.PositionX, Is.EqualTo(inputPosition.x)); + Assert.That(response.PositionY, Is.EqualTo(inputPosition.y)); + Assert.That(response.InterruptedByPausePoint, Is.False); + Assert.That(response.PausePointId, Is.Null); + Assert.That(response.PausePointHitCount, Is.Null); + Assert.That(response.PausePointHits, Is.Null); + } + + /// + /// Test double that records Pause Point state without pausing the Unity Editor. + /// + private sealed class FakePauseController : IUloopPausePointPauseController + { + public bool IsPlaying => true; + public bool IsPaused { get; private set; } + + public void Pause() + { + IsPaused = true; + } + } + } +} +#endif diff --git a/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs.meta b/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs.meta new file mode 100644 index 0000000000..63aa900b74 --- /dev/null +++ b/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ab44557d0cc64ea1877f70eb1e036c1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs new file mode 100644 index 0000000000..7280380e2f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs @@ -0,0 +1,141 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; +using UnityEngine.InputSystem; + +using RuntimeMouseButton = io.github.hatayama.UnityCliLoop.Runtime.MouseButton; +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Restores mouse device and overlay state on the Unity main thread after simulation ends. + /// + internal static class MouseInputMainThreadCleanup + { + internal static async Task ReleaseButtonIfPossible( + Mouse mouse, + RuntimeMouseButton button, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + if (!CanInjectMouseState(mouse)) + { + return InputSimulationWaitOutcome.Completed; + } + + if (EditorApplication.isPaused) + { + ReleaseButtonImmediately(mouse, button); + return InputSimulationWaitOutcome.Completed; + } + + InputSimulationWaitOutcome releaseOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.SetButtonState(mouse, button, false), + ct).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleReleaseButtonImmediately(mouse, button); + } + + return releaseOutcome; + } + + private static void ScheduleReleaseButtonImmediately(Mouse mouse, RuntimeMouseButton button) + { + ReleaseButtonImmediatelyOnMainThreadAsync(mouse, button, CancellationToken.None).Forget(); + } + + private static async Task ReleaseButtonImmediatelyOnMainThreadAsync( + Mouse mouse, + RuntimeMouseButton button, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + ReleaseButtonImmediately(mouse, button); + } + + private static void ReleaseButtonImmediately(Mouse mouse, RuntimeMouseButton button) + { + Debug.Assert(CanInjectMouseState(mouse), "mouse button can only be released while PlayMode has a mouse"); + if (!CanInjectMouseState(mouse)) + { + return; + } + + MouseInputState.SetButtonState(mouse, button, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + } + + internal static void ResetDeltaIfPossible(Mouse mouse) + { + if (!CanInjectMouseState(mouse)) + { + return; + } + + MouseInputState.InjectDelta(mouse, Vector2.zero); + if (EditorApplication.isPaused) + { + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + } + } + + internal static void ScheduleTimedOutButtonCleanup( + Mouse mouse, + RuntimeMouseButton button, + bool pressWasApplied) + { + CleanupTimedOutButtonAsync(mouse, button, pressWasApplied, CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutButtonAsync( + Mouse mouse, + RuntimeMouseButton button, + bool pressWasApplied, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + if (pressWasApplied) + { + await ReleaseButtonIfPossible(mouse, button, ct).ConfigureAwait(false); + } + + MouseInputState.SetButtonUp(button); + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } + + internal static void ScheduleTimedOutMouseOverlayCleanup() + { + CleanupTimedOutMouseOverlayAsync(CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutMouseOverlayAsync(CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + SimulateMouseInputOverlayState.Clear(); + } + + internal static void ScheduleTimedOutDeltaCleanup(Mouse mouse) + { + CleanupTimedOutDeltaAsync(mouse, CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutDeltaAsync(Mouse mouse, CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + ResetDeltaIfPossible(mouse); + SimulateMouseInputOverlayState.Clear(); + } + + private static bool CanInjectMouseState(Mouse mouse) + { + return EditorApplication.isPlaying && mouse != null; + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs.meta new file mode 100644 index 0000000000..88c30e05b4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eea8ef017defc4c7dbc892f72ddc6d8d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputSimulationResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputSimulationResponseFactory.cs new file mode 100644 index 0000000000..19b514aa3b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputSimulationResponseFactory.cs @@ -0,0 +1,116 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Collections.Generic; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Creates wire-visible responses for mouse input simulation outcomes. + /// + internal static class MouseInputSimulationResponseFactory + { + internal static SimulateMouseInputResponse InterruptedButtonResult( + UnityCliLoopMouseInputAction action, + string buttonName, + Vector2 inputPos) + { + SimulateMouseInputResponse result = InterruptedActionResult(action); + result.Button = buttonName; + result.PositionX = inputPos.x; + result.PositionY = inputPos.y; + return result; + } + + internal static SimulateMouseInputResponse InterruptedActionResult( + UnityCliLoopMouseInputAction action) + { + SimulateMouseInputResponse result = new() + { + Success = true, + Message = "Mouse input stopped because Unity paused during Pause Point inspection. Unity CLI Loop released its held input bookkeeping.", + Action = action.ToString(), + InterruptedByPausePoint = true + }; + AttachPausePointHit(result); + return result; + } + + internal static SimulateMouseInputResponse TimedOutButtonResult( + UnityCliLoopMouseInputAction action, + string buttonName, + Vector2 inputPos) + { + SimulateMouseInputResponse result = TimedOutActionResult(action); + result.Button = buttonName; + result.PositionX = inputPos.x; + result.PositionY = inputPos.y; + return result; + } + + internal static SimulateMouseInputResponse TimedOutActionResult( + UnityCliLoopMouseInputAction action) + { + return new SimulateMouseInputResponse + { + Success = false, + Message = "Mouse input timed out while waiting for Unity Editor update. Cleanup is queued for the next Editor tick.", + Action = action.ToString() + }; + } + + private static void AttachPausePointHit(SimulateMouseInputResponse result) + { + if (result == null) + { + Debug.Assert(false, "result must not be null"); + return; + } + + UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + if (snapshot == null) + { + return; + } + + if (!snapshot.IsHit) + { + return; + } + + string? snapshotId = snapshot.Id; + if (string.IsNullOrEmpty(snapshotId)) + { + return; + } + + result.PausePointId = snapshotId; + result.PausePointHitCount = snapshot.HitCount; + result.PausePointHits = CollectPausePointHits(); + } + + // One input can hit several markers in the same frame; the representative + // PausePointId alone forced agents into extra status calls to find the others. + private static List CollectPausePointHits() + { + List hits = new(); + foreach (UloopPausePointSnapshot snapshot in UloopPausePointRegistry.GetHitSnapshots()) + { + if (!snapshot.IsHit || string.IsNullOrEmpty(snapshot.Id)) + { + continue; + } + hits.Add(new UnityCliLoopPausePointHit + { + Id = snapshot.Id, + HitCount = snapshot.HitCount + }); + } + return hits; + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputSimulationResponseFactory.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputSimulationResponseFactory.cs.meta new file mode 100644 index 0000000000..f3ec6c849f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputSimulationResponseFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 455a57fc2aef44c599c2e546689be3d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index f19ea8a3dc..95c34b207f 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -1,9 +1,7 @@ #nullable enable using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using UnityEditor; using UnityEngine; #if ULOOP_HAS_INPUT_SYSTEM using UnityEngine.InputSystem; @@ -181,7 +179,10 @@ private async Task ExecuteClick( () => MouseInputState.SetPositionState(mouse, screenPos), ct).ConfigureAwait(false); if (positionOutcome == InputSimulationWaitOutcome.TimedOut) { - return TimedOutButtonResult(UnityCliLoopMouseInputAction.Click, buttonName, inputPos); + return MouseInputSimulationResponseFactory.TimedOutButtonResult( + UnityCliLoopMouseInputAction.Click, + buttonName, + inputPos); } // Press button @@ -207,16 +208,19 @@ private async Task ExecuteClick( { if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); + MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); } else if (pressWasApplied) { InputSimulationWaitOutcome releaseOutcome = - await ReleaseButtonIfPossible(mouse, button, CancellationToken.None).ConfigureAwait(false); + await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( + mouse, + button, + CancellationToken.None).ConfigureAwait(false); if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) { waitOutcome = InputSimulationWaitOutcome.TimedOut; - ScheduleTimedOutButtonCleanup(mouse, button, false); + MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, false); } else { @@ -245,7 +249,7 @@ private async Task ExecuteClick( if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedButtonResult( + return MouseInputSimulationResponseFactory.InterruptedButtonResult( UnityCliLoopMouseInputAction.Click, buttonName, inputPos); @@ -253,7 +257,10 @@ private async Task ExecuteClick( if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - return TimedOutButtonResult(UnityCliLoopMouseInputAction.Click, buttonName, inputPos); + return MouseInputSimulationResponseFactory.TimedOutButtonResult( + UnityCliLoopMouseInputAction.Click, + buttonName, + inputPos); } string durationText = request.Duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(request.Duration)}s" : ""; @@ -291,7 +298,10 @@ private async Task ExecuteLongPress( () => MouseInputState.SetPositionState(mouse, screenPos), ct).ConfigureAwait(false); if (positionOutcome == InputSimulationWaitOutcome.TimedOut) { - return TimedOutButtonResult(UnityCliLoopMouseInputAction.LongPress, buttonName, inputPos); + return MouseInputSimulationResponseFactory.TimedOutButtonResult( + UnityCliLoopMouseInputAction.LongPress, + buttonName, + inputPos); } // Press button @@ -320,16 +330,19 @@ private async Task ExecuteLongPress( { if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); + MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); } else if (pressWasApplied) { InputSimulationWaitOutcome releaseOutcome = - await ReleaseButtonIfPossible(mouse, button, CancellationToken.None).ConfigureAwait(false); + await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( + mouse, + button, + CancellationToken.None).ConfigureAwait(false); if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) { waitOutcome = InputSimulationWaitOutcome.TimedOut; - ScheduleTimedOutButtonCleanup(mouse, button, false); + MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, false); } else { @@ -358,7 +371,7 @@ private async Task ExecuteLongPress( if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedButtonResult( + return MouseInputSimulationResponseFactory.InterruptedButtonResult( UnityCliLoopMouseInputAction.LongPress, buttonName, inputPos); @@ -366,7 +379,10 @@ private async Task ExecuteLongPress( if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - return TimedOutButtonResult(UnityCliLoopMouseInputAction.LongPress, buttonName, inputPos); + return MouseInputSimulationResponseFactory.TimedOutButtonResult( + UnityCliLoopMouseInputAction.LongPress, + buttonName, + inputPos); } return new SimulateMouseInputResponse @@ -390,8 +406,9 @@ private async Task ExecuteMoveDelta( () => MouseInputState.SetDeltaState(mouse, delta), ct).ConfigureAwait(false); if (applyOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutMouseOverlayCleanup(); - return TimedOutActionResult(UnityCliLoopMouseInputAction.MoveDelta); + MouseInputMainThreadCleanup.ScheduleTimedOutMouseOverlayCleanup(); + return MouseInputSimulationResponseFactory.TimedOutActionResult( + UnityCliLoopMouseInputAction.MoveDelta); } InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) @@ -400,13 +417,15 @@ private async Task ExecuteMoveDelta( { await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); SimulateMouseInputOverlayState.Clear(); - return InterruptedActionResult(UnityCliLoopMouseInputAction.MoveDelta); + return MouseInputSimulationResponseFactory.InterruptedActionResult( + UnityCliLoopMouseInputAction.MoveDelta); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutMouseOverlayCleanup(); - return TimedOutActionResult(UnityCliLoopMouseInputAction.MoveDelta); + MouseInputMainThreadCleanup.ScheduleTimedOutMouseOverlayCleanup(); + return MouseInputSimulationResponseFactory.TimedOutActionResult( + UnityCliLoopMouseInputAction.MoveDelta); } return new SimulateMouseInputResponse @@ -429,8 +448,9 @@ private async Task ExecuteScroll( () => MouseInputState.SetScrollState(mouse, scroll), ct).ConfigureAwait(false); if (applyOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutMouseOverlayCleanup(); - return TimedOutActionResult(UnityCliLoopMouseInputAction.Scroll); + MouseInputMainThreadCleanup.ScheduleTimedOutMouseOverlayCleanup(); + return MouseInputSimulationResponseFactory.TimedOutActionResult( + UnityCliLoopMouseInputAction.Scroll); } InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) @@ -439,13 +459,15 @@ private async Task ExecuteScroll( { await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); SimulateMouseInputOverlayState.Clear(); - return InterruptedActionResult(UnityCliLoopMouseInputAction.Scroll); + return MouseInputSimulationResponseFactory.InterruptedActionResult( + UnityCliLoopMouseInputAction.Scroll); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutMouseOverlayCleanup(); - return TimedOutActionResult(UnityCliLoopMouseInputAction.Scroll); + MouseInputMainThreadCleanup.ScheduleTimedOutMouseOverlayCleanup(); + return MouseInputSimulationResponseFactory.TimedOutActionResult( + UnityCliLoopMouseInputAction.Scroll); } return new SimulateMouseInputResponse @@ -492,8 +514,9 @@ private async Task ExecuteSmoothDelta( () => MouseInputState.InjectDelta(mouse, frameDelta), ct).ConfigureAwait(false); if (applyOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutDeltaCleanup(mouse); - return TimedOutActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + MouseInputMainThreadCleanup.ScheduleTimedOutDeltaCleanup(mouse); + return MouseInputSimulationResponseFactory.TimedOutActionResult( + UnityCliLoopMouseInputAction.SmoothDelta); } previousT = t; @@ -502,15 +525,17 @@ private async Task ExecuteSmoothDelta( if (waitOutcome == InputSimulationWaitOutcome.Paused) { await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - ResetDeltaIfPossible(mouse); + MouseInputMainThreadCleanup.ResetDeltaIfPossible(mouse); SimulateMouseInputOverlayState.Clear(); - return InterruptedActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + return MouseInputSimulationResponseFactory.InterruptedActionResult( + UnityCliLoopMouseInputAction.SmoothDelta); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutDeltaCleanup(mouse); - return TimedOutActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + MouseInputMainThreadCleanup.ScheduleTimedOutDeltaCleanup(mouse); + return MouseInputSimulationResponseFactory.TimedOutActionResult( + UnityCliLoopMouseInputAction.SmoothDelta); } if (t >= 1f) @@ -524,8 +549,9 @@ private async Task ExecuteSmoothDelta( () => MouseInputState.InjectDelta(mouse, Vector2.zero), ct).ConfigureAwait(false); if (resetOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutDeltaCleanup(mouse); - return TimedOutActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + MouseInputMainThreadCleanup.ScheduleTimedOutDeltaCleanup(mouse); + return MouseInputSimulationResponseFactory.TimedOutActionResult( + UnityCliLoopMouseInputAction.SmoothDelta); } return new SimulateMouseInputResponse @@ -536,222 +562,6 @@ private async Task ExecuteSmoothDelta( }; } - private static SimulateMouseInputResponse InterruptedButtonResult( - UnityCliLoopMouseInputAction action, - string buttonName, - Vector2 inputPos) - { - SimulateMouseInputResponse result = InterruptedActionResult(action); - result.Button = buttonName; - result.PositionX = inputPos.x; - result.PositionY = inputPos.y; - return result; - } - - private static SimulateMouseInputResponse InterruptedActionResult( - UnityCliLoopMouseInputAction action) - { - SimulateMouseInputResponse result = new() - { - Success = true, - Message = "Mouse input stopped because Unity paused during Pause Point inspection. Unity CLI Loop released its held input bookkeeping.", - Action = action.ToString(), - InterruptedByPausePoint = true - }; - AttachPausePointHit(result); - return result; - } - - private static SimulateMouseInputResponse TimedOutButtonResult( - UnityCliLoopMouseInputAction action, - string buttonName, - Vector2 inputPos) - { - SimulateMouseInputResponse result = TimedOutActionResult(action); - result.Button = buttonName; - result.PositionX = inputPos.x; - result.PositionY = inputPos.y; - return result; - } - - private static SimulateMouseInputResponse TimedOutActionResult( - UnityCliLoopMouseInputAction action) - { - return new SimulateMouseInputResponse - { - Success = false, - Message = "Mouse input timed out while waiting for Unity Editor update. Cleanup is queued for the next Editor tick.", - Action = action.ToString() - }; - } - - private static void AttachPausePointHit(SimulateMouseInputResponse result) - { - if (result == null) - { - Debug.Assert(false, "result must not be null"); - return; - } - - UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); - if (snapshot == null) - { - return; - } - - if (!snapshot.IsHit) - { - return; - } - - string? snapshotId = snapshot.Id; - if (string.IsNullOrEmpty(snapshotId)) - { - return; - } - - result.PausePointId = snapshotId; - result.PausePointHitCount = snapshot.HitCount; - result.PausePointHits = CollectPausePointHits(); - } - - // One input can hit several markers in the same frame; the representative - // PausePointId alone forced agents into extra status calls to find the others. - private static List CollectPausePointHits() - { - List hits = new(); - foreach (UloopPausePointSnapshot snapshot in UloopPausePointRegistry.GetHitSnapshots()) - { - if (!snapshot.IsHit || string.IsNullOrEmpty(snapshot.Id)) - { - continue; - } - hits.Add(new UnityCliLoopPausePointHit - { - Id = snapshot.Id, - HitCount = snapshot.HitCount - }); - } - return hits; - } - - private static async Task ReleaseButtonIfPossible( - Mouse mouse, - RuntimeMouseButton button, - CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - if (!CanInjectMouseState(mouse)) - { - return InputSimulationWaitOutcome.Completed; - } - - if (EditorApplication.isPaused) - { - ReleaseButtonImmediately(mouse, button); - return InputSimulationWaitOutcome.Completed; - } - - InputSimulationWaitOutcome releaseOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetButtonState(mouse, button, false), - ct).ConfigureAwait(false); - if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) - { - ScheduleReleaseButtonImmediately(mouse, button); - } - - return releaseOutcome; - } - - private static void ScheduleReleaseButtonImmediately(Mouse mouse, RuntimeMouseButton button) - { - ReleaseButtonImmediatelyOnMainThreadAsync(mouse, button, CancellationToken.None).Forget(); - } - - private static async Task ReleaseButtonImmediatelyOnMainThreadAsync( - Mouse mouse, - RuntimeMouseButton button, - CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - ReleaseButtonImmediately(mouse, button); - } - - private static void ReleaseButtonImmediately(Mouse mouse, RuntimeMouseButton button) - { - Debug.Assert(CanInjectMouseState(mouse), "mouse button can only be released while PlayMode has a mouse"); - if (!CanInjectMouseState(mouse)) - { - return; - } - - MouseInputState.SetButtonState(mouse, button, false); - InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); - } - - private static void ResetDeltaIfPossible(Mouse mouse) - { - if (!CanInjectMouseState(mouse)) - { - return; - } - - MouseInputState.InjectDelta(mouse, Vector2.zero); - if (EditorApplication.isPaused) - { - InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); - } - } - - private static void ScheduleTimedOutButtonCleanup(Mouse mouse, RuntimeMouseButton button, bool pressWasApplied) - { - CleanupTimedOutButtonAsync(mouse, button, pressWasApplied, CancellationToken.None).Forget(); - } - - private static async Task CleanupTimedOutButtonAsync( - Mouse mouse, - RuntimeMouseButton button, - bool pressWasApplied, - CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - if (pressWasApplied) - { - await ReleaseButtonIfPossible(mouse, button, ct).ConfigureAwait(false); - } - - MouseInputState.SetButtonUp(button); - SimulateMouseInputOverlayState.SetButtonHeld(button, false); - } - - private static void ScheduleTimedOutMouseOverlayCleanup() - { - CleanupTimedOutMouseOverlayAsync(CancellationToken.None).Forget(); - } - - private static async Task CleanupTimedOutMouseOverlayAsync(CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - SimulateMouseInputOverlayState.Clear(); - } - - private static void ScheduleTimedOutDeltaCleanup(Mouse mouse) - { - CleanupTimedOutDeltaAsync(mouse, CancellationToken.None).Forget(); - } - - private static async Task CleanupTimedOutDeltaAsync(Mouse mouse, CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - ResetDeltaIfPossible(mouse); - SimulateMouseInputOverlayState.Clear(); - } - - private static bool CanInjectMouseState(Mouse mouse) - { - return EditorApplication.isPlaying && mouse != null; - } - private static RuntimeMouseButton ToRuntimeMouseButton(UnityCliLoopMouseButton button) { switch (button)