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
56 changes: 56 additions & 0 deletions Assets/Tests/Editor/MouseUiMainThreadCleanupSchedulerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using NUnit.Framework;
using UnityEngine;

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

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Characterizes synchronous mouse UI cleanup scheduling.
/// </summary>
[TestFixture]
public sealed class MouseUiMainThreadCleanupSchedulerTests
{
[TearDown]
public void TearDown()
{
SimulateMouseUiOverlayState.Clear();
}

/// <summary>
/// Verifies cleanup executes immediately when invoked from the Unity main thread.
/// </summary>
[Test]
public void ExecuteCleanupOnMainThread_AfterContextCapture_RunsImmediately()
{
MouseUiMainThreadCleanupScheduler scheduler = new();
scheduler.CaptureMainThreadContext();
bool cleanupRan = false;

scheduler.ExecuteCleanupOnMainThread(() => cleanupRan = true);

Assert.That(cleanupRan, Is.True);
}

/// <summary>
/// Verifies queued overlay cleanup clears active visualization state on the main thread.
/// </summary>
[Test]
public void QueueOverlayClear_WithActiveOverlayState_ClearsState()
{
SimulateMouseUiOverlayState.Update(
MouseAction.Click,
new Vector2(10f, 20f),
null,
"Target",
new Vector2(100f, 200f));
MouseUiMainThreadCleanupScheduler scheduler = new();
scheduler.CaptureMainThreadContext();

scheduler.QueueOverlayClear();

Assert.That(SimulateMouseUiOverlayState.IsActive, Is.False);
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/MouseUiMainThreadCleanupSchedulerTests.cs.meta

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,30 @@
#nullable enable
using System.Threading;
using System.Threading.Tasks;

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

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Waits for an Editor frame and restores execution to the Unity main thread.
/// </summary>
internal static class MouseUiEditorFrameWaiter
{
internal static async Task<bool> WaitForEditorFrameAndSwitchToMainThreadAsync(CancellationToken ct)
{
bool frameReady = await EditorFrameWaiter.WaitFramesOrTimeoutAsync(
1,
UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS,
ct).ConfigureAwait(false);
if (!frameReady)
{
return false;
}

await MainThreadSwitcher.SwitchToMainThread(ct);
return true;
}
}
}

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,54 @@
#nullable enable
using System;
using System.Threading;
using UnityEngine;

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

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Schedules mouse UI cleanup on the captured Unity main thread.
/// </summary>
internal sealed class MouseUiMainThreadCleanupScheduler
{
private SynchronizationContext? _mainThreadContext;

internal void CaptureMainThreadContext()
{
_mainThreadContext = SynchronizationContext.Current;
Debug.Assert(_mainThreadContext != null, "Main thread synchronization context must be captured.");
}

internal void QueueOverlayClear()
{
ExecuteCleanupOnMainThread(SimulateMouseUiOverlayState.Clear);
}

internal void ExecuteCleanupOnMainThread(Action cleanup)
{
Debug.Assert(cleanup != null, "cleanup must not be null");
if (cleanup == null)
{
throw new ArgumentNullException(nameof(cleanup));
}

if (MainThreadSwitcher.IsMainThread)
{
cleanup();
return;
}

SynchronizationContext? context = _mainThreadContext;
Debug.Assert(context != null, "Main thread synchronization context must be captured before cleanup.");
if (context == null)
{
throw new InvalidOperationException("Main thread synchronization context was not captured.");
}

// Why: timeout continuations can run on timer threads while Unity objects must still be cleaned up on the Editor thread.
context.Post(_ => cleanup(), null);
}
}
}

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,66 @@
#nullable enable
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

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

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Animates the mouse UI overlay around simulated pointer actions.
/// </summary>
internal static class MouseUiOverlayAnimator
{
internal static async Task<bool> PlayExpandAnimation(CancellationToken ct)
{
SimulateMouseUiOverlay overlay = OverlayCanvasFactory.VisualizationCanvas.MouseUiOverlay;

// Previous dissipate sets alpha to 0; restore before expand starts
overlay.SetAlpha(1f);

float startTime = Time.realtimeSinceStartup;
float elapsed = 0f;
while (elapsed < SimulateMouseUiAnimationConstants.EXPAND_DURATION)
{
float t = elapsed / SimulateMouseUiAnimationConstants.EXPAND_DURATION;
overlay.SetCursorScale(Mathf.Lerp(SimulateMouseUiAnimationConstants.EXPAND_START_SCALE, 1f, t));
bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false);
if (!frameReady)
{
return false;
}
await MainThreadSwitcher.SwitchToMainThread(ct);
elapsed = Time.realtimeSinceStartup - startTime;
}
overlay.SetCursorScale(1f);
return true;
}

internal static async Task<bool> PlayDissipateAnimation(CancellationToken ct)
{
SimulateMouseUiOverlay overlay = OverlayCanvasFactory.VisualizationCanvas.MouseUiOverlay;

float startTime = Time.realtimeSinceStartup;
float elapsed = 0f;
while (elapsed < SimulateMouseUiAnimationConstants.DISSIPATE_DURATION)
{
float t = elapsed / SimulateMouseUiAnimationConstants.DISSIPATE_DURATION;
overlay.SetCursorScale(Mathf.Lerp(1f, 0f, t));
overlay.SetAlpha(Mathf.Lerp(1f, 0f, t));
bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false);
if (!frameReady)
{
return false;
}
await MainThreadSwitcher.SwitchToMainThread(ct);
elapsed = Time.realtimeSinceStartup - startTime;
}
overlay!.SetCursorScale(0f);
overlay!.SetAlpha(0f);
SimulateMouseUiOverlayState.Clear();
return true;
}
}
}

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,42 @@
#nullable enable
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Writes structured activity logs for mouse UI simulations.
/// </summary>
internal static class MouseUiSimulationActivityLogger
{
internal static void LogSimulationStart(MouseUiSimulationCommand parameters, string correlationId)
{
VibeLogger.LogInfo(
"simulate_mouse_start",
"Mouse simulation started",
new
{
Action = parameters.Action.ToString(),
X = parameters.X,
Y = parameters.Y,
BypassRaycast = parameters.BypassRaycast,
TargetPath = parameters.TargetPath,
DropTargetPath = parameters.DropTargetPath
},
correlationId: correlationId
);
}

internal static void LogSimulationComplete(
MouseUiSimulationCommand parameters,
SimulateMouseUiResponse response,
string correlationId)
{
VibeLogger.LogInfo(
"simulate_mouse_complete",
$"Mouse simulation completed: {response.Message}",
new { Action = parameters.Action.ToString(), Success = response.Success },
correlationId: correlationId
);
}
}
}

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

Loading
Loading