diff --git a/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs b/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs index 613d00c765..f8dd3806bc 100644 --- a/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs +++ b/Assets/Tests/PlayMode/MouseInputSimulationResponseFactoryTests.cs @@ -52,7 +52,7 @@ public void InterruptedButtonResult_WithMultiplePausePointHits_MapsPauseEvidence Assert.That( response.Message, Is.EqualTo( - "Mouse input stopped because Unity paused during Pause Point inspection. Button 'Left' was released from Unity CLI Loop bookkeeping; the queued input edge was discarded.")); + "Mouse input stopped because Unity paused during Pause Point inspection. Button 'Left' was released from Unity CLI Loop bookkeeping; the queued input edge was discarded before any gameplay update processed it, so the game never observed a press and it is safe to retry after resume.")); Assert.That(response.Action, Is.EqualTo(UnityCliLoopMouseInputAction.Click.ToString())); Assert.That(response.Button, Is.EqualTo("Left")); Assert.That(response.PositionX, Is.EqualTo(inputPosition.x)); @@ -83,7 +83,7 @@ public void InterruptedButtonResult_WhenPressWasApplied_ReportsDeliveredBeforePa Assert.That( response.Message, Is.EqualTo( - "Mouse input stopped because Unity paused during Pause Point inspection. Button 'Right' press was already delivered to the game before the pause; Unity CLI Loop released it from bookkeeping, so the game may have registered the press.")); + "Mouse input stopped because Unity paused during Pause Point inspection. Button 'Right' press was delivered to the game before the pause: the Input System processed the press edge in a gameplay update, so game code polling that frame observed it and the world state may already have changed. Do not retry the press; re-check the affected state (and pause-point-status) before deciding the next step.")); Assert.That(response.Action, Is.EqualTo(UnityCliLoopMouseInputAction.LongPress.ToString())); Assert.That(response.Button, Is.EqualTo("Right")); Assert.That(response.PositionX, Is.EqualTo(inputPosition.x)); diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index d23e5d15d9..c4a238b4ea 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -197,6 +197,38 @@ public IEnumerator LongPress_Should_HoldButtonForDuration() AssertCoordinateMetadata(400f, 300f); } + [UnityTest] + public IEnumerator LongPress_WhenUnityPausesDuringObservation_Should_ReleaseButton() + { + // Verifies a pause-point pause during a long press releases the injected mouse button state. + yield return null; + + Task task = tool.ExecuteAsync(new JObject + { + ["action"] = MouseInputAction.LongPress.ToString(), + ["x"] = 400, + ["y"] = 300, + ["duration"] = 1f + }, System.Threading.CancellationToken.None); + + float pressDeadline = Time.realtimeSinceStartup + 5f; + yield return new WaitUntil(() => + mouse.leftButton.isPressed || task.IsCompleted || Time.realtimeSinceStartup >= pressDeadline); + Assert.IsTrue(mouse.leftButton.isPressed, "The press must be applied before the pause is simulated."); + Assert.IsFalse(task.IsCompleted, "The test must pause during the long-press observation window."); + + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = (SimulateMouseInputResponse)task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByPausePoint); + Assert.AreEqual("LongPress", lastResponse.Action); + Assert.IsFalse(mouse.leftButton.isPressed, "Pause-point interruption should release the injected mouse button state."); + Assert.IsFalse(SimulateMouseInputOverlayState.HasAnyActivity, "Pause-point interruption should clear mouse overlay state."); + } + [UnityTest] public IEnumerator LongPress_WithZeroDuration_Should_ReturnError() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs index 7280380e2f..23a2b5b519 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs @@ -59,6 +59,15 @@ private static async Task ReleaseButtonImmediatelyOnMainThreadAsync( ReleaseButtonImmediately(mouse, button); } + /// + /// Releases the button right away after a pause interrupted the press, once the pending apply + /// subscription has been disposed. + /// + internal static void ReleaseButtonImmediatelyAfterPauseInterruption(Mouse mouse, RuntimeMouseButton button) + { + 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"); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs index 23222eb452..5c344f6c1a 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs @@ -85,45 +85,8 @@ internal static async Task ExecuteClick( } finally { - if (waitOutcome == InputSimulationWaitOutcome.TimedOut) - { - MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); - } - else if (pressWasApplied) - { - InputSimulationWaitOutcome releaseOutcome = - await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( - mouse, - button, - CancellationToken.None).ConfigureAwait(false); - if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) - { - waitOutcome = InputSimulationWaitOutcome.TimedOut; - MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, false); - } - else - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - MouseInputState.SetButtonUp(button); - } - } - else - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - MouseInputState.SetButtonUp(button); - } - - if (waitOutcome != InputSimulationWaitOutcome.TimedOut) - { - if (waitOutcome == InputSimulationWaitOutcome.Paused) - { - SimulateMouseInputOverlayState.Clear(); - } - else - { - SimulateMouseInputOverlayState.SetButtonHeld(button, false); - } - } + waitOutcome = await FinishHeldButton(mouse, button, waitOutcome, pressWasApplied) + .ConfigureAwait(false); } if (waitOutcome == InputSimulationWaitOutcome.Paused) @@ -216,45 +179,8 @@ internal static async Task ExecuteLongPress( } finally { - if (waitOutcome == InputSimulationWaitOutcome.TimedOut) - { - MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); - } - else if (pressWasApplied) - { - InputSimulationWaitOutcome releaseOutcome = - await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( - mouse, - button, - CancellationToken.None).ConfigureAwait(false); - if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) - { - waitOutcome = InputSimulationWaitOutcome.TimedOut; - MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, false); - } - else - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - MouseInputState.SetButtonUp(button); - } - } - else - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - MouseInputState.SetButtonUp(button); - } - - if (waitOutcome != InputSimulationWaitOutcome.TimedOut) - { - if (waitOutcome == InputSimulationWaitOutcome.Paused) - { - SimulateMouseInputOverlayState.Clear(); - } - else - { - SimulateMouseInputOverlayState.SetButtonHeld(button, false); - } - } + waitOutcome = await FinishHeldButton(mouse, button, waitOutcome, pressWasApplied) + .ConfigureAwait(false); } if (waitOutcome == InputSimulationWaitOutcome.Paused) @@ -282,6 +208,80 @@ await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( conversion); } + /// + /// Releases a button that a finished press left held and returns the outcome the caller must + /// report; a release that timed out downgrades the outcome to TimedOut. + /// + private static async Task FinishHeldButton( + Mouse mouse, + RuntimeMouseButton button, + InputSimulationWaitOutcome waitOutcome, + bool pressWasApplied) + { + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); + return waitOutcome; + } + + if (waitOutcome == InputSimulationWaitOutcome.Paused && pressWasApplied) + { + return await ReleaseHeldButtonAfterPause(mouse, button).ConfigureAwait(false); + } + + if (pressWasApplied) + { + InputSimulationWaitOutcome releaseOutcome = + await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( + mouse, + button, + CancellationToken.None).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, false); + return InputSimulationWaitOutcome.TimedOut; + } + + // The pause can also arrive while this release is queued, which discards it the same way. + if (releaseOutcome == InputSimulationWaitOutcome.Paused) + { + return await ReleaseHeldButtonAfterPause(mouse, button).ConfigureAwait(false); + } + } + + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + MouseInputState.SetButtonUp(button); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + } + else + { + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } + + return waitOutcome; + } + + /// + /// Writes the release straight into the device after a pause discarded the queued one, and + /// reports the paused outcome. + /// + private static async Task ReleaseHeldButtonAfterPause( + Mouse mouse, + RuntimeMouseButton button) + { + // Why: ApplyOnNextConfiguredUpdate already disposed the pending apply subscription when it + // returned Paused, so a release routed through it again is discarded and the button stays + // held until the next command. Writing after that dispose also keeps a queued edge from + // re-applying the press once the Editor resumes. + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + MouseInputMainThreadCleanup.ReleaseButtonImmediatelyAfterPauseInterruption(mouse, button); + MouseInputState.SetButtonUp(button); + SimulateMouseInputOverlayState.Clear(); + return InputSimulationWaitOutcome.Paused; + } + private static RuntimeMouseButton ToRuntimeMouseButton(UnityCliLoopMouseButton button) { switch (button)