From fd70944b4a1857658019ce3b75838804f90c8c45 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 20 Sep 2026 08:31:21 +0900 Subject: [PATCH 1/3] test: Align interrupted mouse press expectations with the shipped wording The interrupted-press messages were rewritten when the response started reporting whether the press reached the game, and a new EditMode test file was added with the new wording, but this older PlayMode file kept asserting the previous strings and has failed ever since. The PlayMode file still covers pause evidence mapping, hit ordering, and coordinates that the EditMode file does not, so the expectations are updated in place instead of removing the file. --- .../PlayMode/MouseInputSimulationResponseFactoryTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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)); From 3c4a2b80f498dc7d615a52d0a925850273af0d75 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 20 Sep 2026 08:35:06 +0900 Subject: [PATCH 2/3] fix: Release the mouse button when a pause point interrupts a press A press interrupted by a pause point could leave the simulated button held in the device state. The cleanup path chooses between an immediate release and a release scheduled on the next configured update by reading the Editor pause flag directly, while the applier reads the injectable pause source. When those two disagree the applier discards the scheduled release as paused, and the executor, which only reacts to a timed-out release, leaves the button down; on resume the game can still see it pressed. Click and long press now write the release straight into the device when the press was applied and the wait ended paused, mirroring the keyboard path, and share one helper for finishing a held button. --- .../Tests/PlayMode/SimulateMouseInputTests.cs | 29 ++++ .../MouseInputMainThreadCleanup.cs | 9 ++ .../MouseInputPressActionExecutor.cs | 139 ++++++++---------- 3 files changed, 99 insertions(+), 78 deletions(-) diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index d23e5d15d9..850ffa21ac 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -197,6 +197,35 @@ 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); + + yield return new WaitUntil(() => mouse.leftButton.isPressed || task.IsCompleted); + 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..8beaa0890a 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,63 @@ 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) + { + // 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. Write the release straight into the device after + // that dispose, so no queued edge can re-apply the press once the Editor resumes. + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + MouseInputMainThreadCleanup.ReleaseButtonImmediatelyAfterPauseInterruption(mouse, button); + MouseInputState.SetButtonUp(button); + SimulateMouseInputOverlayState.Clear(); + return waitOutcome; + } + + 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; + } + } + + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + MouseInputState.SetButtonUp(button); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + } + else + { + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } + + return waitOutcome; + } + private static RuntimeMouseButton ToRuntimeMouseButton(UnityCliLoopMouseButton button) { switch (button) From 4dc5890631a402f5cab3f17a5d984142b8d9be70 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 20 Sep 2026 08:43:01 +0900 Subject: [PATCH 3/3] fix: Release the mouse button when the pause arrives during the queued release The pause can also land after the press lifetime ended but before the queued release applies. The applier discards that release and reports it as paused, which left the button held for the same reason as the interrupted press. Both paths now share one release, and the new long-press test bounds its initial wait so a press that never applies fails the test instead of hanging the PlayMode run. --- .../Tests/PlayMode/SimulateMouseInputTests.cs | 5 ++- .../MouseInputPressActionExecutor.cs | 35 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index 850ffa21ac..c4a238b4ea 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -211,7 +211,10 @@ public IEnumerator LongPress_WhenUnityPausesDuringObservation_Should_ReleaseButt ["duration"] = 1f }, System.Threading.CancellationToken.None); - yield return new WaitUntil(() => mouse.leftButton.isPressed || task.IsCompleted); + 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); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs index 8beaa0890a..5c344f6c1a 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs @@ -226,15 +226,7 @@ private static async Task FinishHeldButton( if (waitOutcome == InputSimulationWaitOutcome.Paused && pressWasApplied) { - // 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. Write the release straight into the device after - // that dispose, so no queued edge can re-apply the press once the Editor resumes. - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - MouseInputMainThreadCleanup.ReleaseButtonImmediatelyAfterPauseInterruption(mouse, button); - MouseInputState.SetButtonUp(button); - SimulateMouseInputOverlayState.Clear(); - return waitOutcome; + return await ReleaseHeldButtonAfterPause(mouse, button).ConfigureAwait(false); } if (pressWasApplied) @@ -249,6 +241,12 @@ await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( 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); @@ -265,6 +263,25 @@ await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( 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)