diff --git a/.claude-plugin/skills/jaction/SKILL.md b/.claude-plugin/skills/jaction/SKILL.md index 1aad02a3..ea96e73c 100644 --- a/.claude-plugin/skills/jaction/SKILL.md +++ b/.claude-plugin/skills/jaction/SKILL.md @@ -1,79 +1,130 @@ --- name: jaction -description: JAction fluent chainable task system for Unity. Triggers on: sequential tasks, delay, timer, repeat loop, WaitUntil, WaitWhile, async workflow, zero-allocation async, coroutine alternative, scheduled action, timed event, polling condition, action sequence, ExecuteAsync +description: JAction fluent chainable task system for Unity. Triggers on: sequential tasks, delay, timer, repeat loop, WaitUntil, WaitWhile, async workflow, zero-allocation async, coroutine alternative, scheduled action, timed event, polling condition, action sequence, ExecuteAsync, parallel execution --- # JAction - Chainable Task Execution -Fluent API for composing complex action sequences in Unity with automatic object pooling and zero-allocation async. +Fluent API for composing complex action sequences in Unity with automatic object pooling, zero-allocation async, and parallel execution support. ## When to Use + - Sequential workflows with delays - Polling conditions (WaitUntil/WaitWhile) - Repeat loops with intervals - Game timers and scheduled events - Zero-GC async operations +- Parallel concurrent executions + +## Core Concepts + +### Task Snapshot Isolation + +When `Execute()` or `ExecuteAsync()` is called, the current task list is **snapshotted**. Modifications to the JAction after execution starts do NOT affect running executions: + +```csharp +var action = JAction.Create() + .Delay(1f) + .Do(static () => Debug.Log("Original")); -## Properties -- `.Executing` - Returns true if currently executing -- `.Cancelled` - Returns true if execution was cancelled -- `.IsParallel` - Returns true if parallel mode enabled +var handle = action.ExecuteAsync(); -## Core API +// This task is NOT executed by the handle above - it was added after the snapshot +action.Do(static () => Debug.Log("Added Later")); + +await handle; // Only prints "Original" +``` -### Execution Methods -- `.Execute(float timeout = 0)` - Synchronous execution (BLOCKS main thread - use sparingly) -- `.ExecuteAsync(float timeout = 0)` - Asynchronous via PlayerLoop (RECOMMENDED) +This isolation enables safe parallel execution where each handle operates on its own task snapshot. -### Action Execution -- `.Do(Action)` - Execute synchronous action -- `.Do(Action, TState)` - Execute with state (zero-alloc for reference types) -- `.Do(Func)` - Execute async action -- `.Do(Func, TState)` - Async with state +### Return Types -### Delays & Waits -- `.Delay(float seconds)` - Wait specified seconds -- `.DelayFrame(int frames)` - Wait specified frame count -- `.WaitUntil(Func, frequency, timeout)` - Wait until condition true -- `.WaitUntil(Func, TState, frequency, timeout)` - With state -- `.WaitWhile(Func, frequency, timeout)` - Wait while condition true -- `.WaitWhile(Func, TState, frequency, timeout)` - With state +**JActionExecution** (returned by Execute, awaited from ExecuteAsync): +- `.Action` - The JAction that was executed +- `.Cancelled` - Whether THIS specific execution was cancelled +- `.Executing` - Whether the action is still executing +- `.Dispose()` - Returns JAction to pool + +**JActionExecutionHandle** (returned by ExecuteAsync before await): +- `.Action` - The JAction being executed +- `.Cancelled` - Whether this execution is cancelled +- `.Executing` - Whether still running +- `.Cancel()` - Cancel THIS specific execution +- `.AsUniTask()` - Convert to `UniTask` +- Awaitable: `await handle` returns `JActionExecution` + +## API Reference + +### Execution + +| Method | Returns | Description | +|--------|---------|-------------| +| `.Execute(timeout)` | `JActionExecution` | Synchronous blocking execution | +| `.ExecuteAsync(timeout)` | `JActionExecutionHandle` | Async via PlayerLoop (recommended) | + +### Actions + +| Method | Description | +|--------|-------------| +| `.Do(Action)` | Execute synchronous action | +| `.Do(Action, T)` | Execute with state (zero-alloc for reference types) | +| `.Do(Func)` | Execute async action | +| `.Do(Func, T)` | Async with state | + +### Timing + +| Method | Description | +|--------|-------------| +| `.Delay(seconds)` | Wait specified seconds | +| `.DelayFrame(frames)` | Wait specified frame count | +| `.WaitUntil(condition, frequency, timeout)` | Wait until condition true | +| `.WaitWhile(condition, frequency, timeout)` | Wait while condition true | ### Loops -- `.Repeat(Action, count, interval)` - Repeat N times -- `.Repeat(Action, TState, count, interval)` - With state -- `.RepeatWhile(Action, Func, frequency, timeout)` - Repeat while condition -- `.RepeatWhile(Action, Func, TState, frequency, timeout)` - With state -- `.RepeatUntil(Action, Func, frequency, timeout)` - Repeat until condition -- `.RepeatUntil(Action, Func, TState, frequency, timeout)` - With state + +| Method | Description | +|--------|-------------| +| `.Repeat(action, count, interval)` | Repeat N times | +| `.RepeatWhile(action, condition, frequency, timeout)` | Repeat while condition true | +| `.RepeatUntil(action, condition, frequency, timeout)` | Repeat until condition true | + +All loop methods have `` overloads for zero-allocation with reference types. ### Configuration -- `.Parallel()` - Enable concurrent execution -- `.OnCancel(Action)` - Register cancellation callback -- `.OnCancel(Action, TState)` - With state -### Lifecycle -- `.Cancel()` - Stop execution -- `.Reset()` - Clear state for reuse -- `.Dispose()` - Return to object pool -- `JAction.PooledCount` - Check pooled instances -- `JAction.ClearPool()` - Empty the pool +| Method | Description | +|--------|-------------| +| `.Parallel()` | Enable concurrent execution mode | +| `.OnCancel(callback)` | Register cancellation callback | +| `.Cancel()` | Stop ALL active executions | +| `.Reset()` | Clear state for reuse | +| `.Dispose()` | Return to object pool | + +### Static Members + +| Member | Description | +|--------|-------------| +| `JAction.Create()` | Get pooled instance | +| `JAction.PooledCount` | Check available pooled instances | +| `JAction.ClearPool()` | Empty the pool | ## Patterns -### Basic Sequence (use ExecuteAsync in production) +### Basic Sequence + ```csharp -using var action = await JAction.Create() +using var result = await JAction.Create() .Do(static () => Debug.Log("Step 1")) .Delay(1f) .Do(static () => Debug.Log("Step 2")) .ExecuteAsync(); ``` -### Always Use `using var` for Async (CRITICAL) +### Always Use `using var` (CRITICAL) + ```csharp // CORRECT - auto-disposes and returns to pool -using var action = await JAction.Create() +using var result = await JAction.Create() .Do(() => LoadAsset()) .WaitUntil(() => assetLoaded) .ExecuteAsync(); @@ -84,74 +135,125 @@ await JAction.Create() .ExecuteAsync(); ``` -### State Parameter for Zero-Allocation (Reference Types Only) +### Parallel Execution with Per-Execution Cancellation + ```csharp -// CORRECT - no closure allocation with reference types +var action = JAction.Create() + .Parallel() + .Do(static () => Debug.Log("Start")) + .Delay(5f) + .Do(static () => Debug.Log("Done")); + +// Start multiple concurrent executions (each gets own task snapshot) +var handle1 = action.ExecuteAsync(); +var handle2 = action.ExecuteAsync(); + +// Cancel only the first execution +handle1.Cancel(); + +// Each has independent Cancelled state +var result1 = await handle1; // result1.Cancelled == true +var result2 = await handle2; // result2.Cancelled == false + +action.Dispose(); +``` + +### UniTask.WhenAll with Parallel + +```csharp +var action = JAction.Create() + .Parallel() + .Delay(1f) + .Do(static () => Debug.Log("Done")); + +var handle1 = action.ExecuteAsync(); +var handle2 = action.ExecuteAsync(); + +await UniTask.WhenAll(handle1.AsUniTask(), handle2.AsUniTask()); + +action.Dispose(); +``` + +### Zero-Allocation with Reference Types + +```csharp +// CORRECT - static lambda + reference type state = zero allocation var data = new MyData(); JAction.Create() .Do(static (MyData d) => d.Process(), data) .Execute(); -// WARNING: State overloads DO NOT work with value types (int, float, struct, bool, etc.) -// Value types get boxed when passed as generic parameters, defeating zero-allocation -// For value types, use closures instead (allocation is acceptable): +// Pass 'this' when inside a class - no wrapper needed +public class Enemy : MonoBehaviour +{ + public bool IsStunned; + + public void ApplyStun(float duration) + { + IsStunned = true; + JAction.Create() + .Delay(duration) + .Do(static (Enemy self) => self.IsStunned = false, this) + .ExecuteAsync().Forget(); + } +} + +// Value types use closures (boxing would defeat zero-alloc anyway) int count = 5; JAction.Create() - .Do(() => Debug.Log($"Count: {count}")) // Closure is fine for value types + .Do(() => Debug.Log($"Count: {count}")) .Execute(); ``` -### Set Timeouts for Production +### Timeout Handling + ```csharp -using var action = await JAction.Create() +using var result = await JAction.Create() .WaitUntil(() => networkReady) - .ExecuteAsync(timeout: 30f); // Prevents infinite waits + .ExecuteAsync(timeout: 30f); + +if (result.Cancelled) + Debug.Log("Timed out!"); ``` -### With Cancellation +### Cancellation Callback + ```csharp var action = JAction.Create() .OnCancel(() => Debug.Log("Cancelled!")) - .Do(() => LongRunningTask()); + .Delay(10f); -var task = action.ExecuteAsync(); -// Later... -action.Cancel(); +var handle = action.ExecuteAsync(); +handle.Cancel(); // Triggers OnCancel callback ``` ## Game Patterns -All patterns use `ExecuteAsync()` for non-blocking execution. +### Cooldown Timer -### Cooldown Timer (Zero-GC) ```csharp -public sealed class AbilityState -{ - public bool CanUse = true; -} - public class AbilitySystem { - private readonly AbilityState _state = new(); - private readonly float _cooldown; + public bool CanUse = true; - public async UniTaskVoid TryUseAbility() + public async UniTaskVoid TryUseAbility(float cooldown) { - if (!_state.CanUse) return; - _state.CanUse = false; + if (!CanUse) return; + CanUse = false; PerformAbility(); - // Zero-GC: static lambda + reference type state - using var action = await JAction.Create() - .Delay(_cooldown) - .Do(static s => s.CanUse = true, _state) + // Pass 'this' as state - no extra class needed + using var _ = await JAction.Create() + .Delay(cooldown) + .Do(static s => s.CanUse = true, this) .ExecuteAsync(); } } ``` -### Damage Over Time (Zero-GC) +### Damage Over Time + ```csharp public sealed class DoTState { @@ -159,35 +261,32 @@ public sealed class DoTState public float DamagePerTick; } -public static async UniTaskVoid ApplyDoT(IDamageable target, float damage, int ticks, float interval) +public static async UniTaskVoid ApplyDoT( + IDamageable target, float damage, int ticks, float interval) { - // Rent state from pool to avoid allocation var state = JObjectPool.Shared().Rent(); state.Target = target; state.DamagePerTick = damage; - using var action = await JAction.Create() + using var _ = await JAction.Create() .Repeat( static s => s.Target?.TakeDamage(s.DamagePerTick), - state, - count: ticks, - interval: interval) + state, count: ticks, interval: interval) .ExecuteAsync(); - // Return state to pool state.Target = null; JObjectPool.Shared().Return(state); } ``` -### Wave Spawner (Async) +### Wave Spawner + ```csharp -// Async methods cannot use ReadOnlySpan (ref struct), use array instead public async UniTask RunWaves(WaveConfig[] waves) { foreach (var wave in waves) { - using var action = await JAction.Create() + using var result = await JAction.Create() .Do(() => UI.ShowWaveStart(wave.Number)) .Delay(2f) .Do(() => SpawnWave(wave)) @@ -195,74 +294,47 @@ public async UniTask RunWaves(WaveConfig[] waves) .Delay(wave.DelayAfter) .ExecuteAsync(); - if (action.Cancelled) break; - } -} - -// Sync methods can use ReadOnlySpan for zero-allocation iteration -public void RunWavesSync(ReadOnlySpan waves) -{ - foreach (ref readonly var wave in waves) - { - using var action = JAction.Create() - .Do(() => UI.ShowWaveStart(wave.Number)) - .Delay(2f) - .Do(() => SpawnWave(wave)) - .WaitUntil(() => ActiveEnemyCount == 0, timeout: 120f) - .Delay(wave.DelayAfter); - action.Execute(); - - if (action.Cancelled) break; + if (result.Cancelled) break; } } ``` -### Health Regeneration (Zero-GC) +### Health Regeneration + ```csharp public sealed class RegenState { - public float Health; - public float MaxHealth; - public float HpPerTick; + public float Health, MaxHealth, HpPerTick; } public static async UniTaskVoid StartRegen(RegenState state) { - using var action = await JAction.Create() + using var _ = await JAction.Create() .RepeatWhile( static s => s.Health = MathF.Min(s.Health + s.HpPerTick, s.MaxHealth), static s => s.Health < s.MaxHealth, - state, - frequency: 0.1f) + state, frequency: 0.1f) .ExecuteAsync(); } ``` ## Troubleshooting -### Nothing Happens -- **Forgot ExecuteAsync:** Must call `.ExecuteAsync()` at the end -- **Already disposed:** Don't reuse a JAction after Dispose() - -### Memory Leak -- **Missing `using var`:** Always use `using var action = await ...ExecuteAsync()` -- **Infinite loop:** Set timeouts on WaitUntil/WaitWhile in production - -### Frame Drops -- **Using Execute():** Switch to ExecuteAsync() for non-blocking -- **Heavy callbacks:** Keep .Do() callbacks lightweight - -### Unexpected Behavior -- **Value type state:** State overloads box value types; wrap in reference type -- **Check Cancelled:** After timeout, check `action.Cancelled` before continuing - -### GC Allocations -- **Closures:** Use static lambdas with state parameters -- **State must be reference type:** Value types get boxed +| Problem | Cause | Solution | +|---------|-------|----------| +| Nothing happens | Forgot to call Execute/ExecuteAsync | Add `.ExecuteAsync()` at the end | +| Memory leak | Missing `using var` | Always use `using var result = await ...` | +| Frame drops | Using `Execute()` | Switch to `ExecuteAsync()` | +| GC allocations | Closures with reference types | Use static lambda + state parameter | +| Unexpected timing | Value type state | Wrap in reference type or use closure | +| Handle shows wrong Cancelled | Reading after modification | Snapshot is isolated - this is expected | ## Common Mistakes -- NOT using `using var` after ExecuteAsync (memory leak, never returns to pool) -- Using Execute() in production (blocks main thread, causes frame drops) -- Using state overloads with value types (causes boxing - use closures instead) -- Forgetting to call Execute() or ExecuteAsync() (nothing happens) -- Code in .Do() runs atomically and cannot be interrupted - keep callbacks lightweight + +1. **Missing `using var`** - Memory leak, JAction never returns to pool +2. **Using `Execute()` in production** - Blocks main thread, causes frame drops +3. **State overloads with value types** - Causes boxing; use closures instead +4. **Forgetting Execute/ExecuteAsync** - Nothing happens +5. **Heavy work in `.Do()`** - Callbacks run atomically; keep them lightweight +6. **Using `action.Cancel()` in parallel** - Cancels ALL executions; use `handle.Cancel()` for specific execution +7. **Modifying JAction after ExecuteAsync** - Changes don't affect running execution (task snapshot isolation) diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs index b66dbd63..da5f517c 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs @@ -25,6 +25,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using Cysharp.Threading.Tasks; using TMPro; @@ -124,6 +125,64 @@ private static GameObject Prefab internal static bool SimulateNoPrefab; #endif +#if UNITY_INCLUDE_TESTS + /// + /// Test hook: Gets the pool state for verification in tests. + /// Returns (activeCount, pooledCount). + /// + internal static (int activeCount, int pooledCount) TestGetPoolState() + { + return (ActiveMessageBoxes.Count, PooledMessageBoxes.Count); + } + + /// + /// Test hook: Simulates clicking a button on the most recently shown message box. + /// + /// If true, simulates clicking OK; otherwise simulates clicking Cancel. + /// True if a message box was found and the click was simulated. + internal static bool TestSimulateButtonClick(bool clickOk) + { + if (ActiveMessageBoxes.Count == 0) return false; + + // Get the first message box (any will do for testing) + var target = ActiveMessageBoxes.First(); + target.HandleEvent(clickOk); + return true; + } + + /// + /// Test hook: Gets the button visibility state of the most recently shown message box. + /// + /// Tuple of (okButtonVisible, noButtonVisible), or null if no active boxes. + internal static (bool okVisible, bool noVisible)? TestGetButtonVisibility() + { + if (ActiveMessageBoxes.Count == 0) return null; + + var target = ActiveMessageBoxes.First(); + if (target._buttonOk == null || target._buttonNo == null) + return null; + + return (target._buttonOk.gameObject.activeSelf, target._buttonNo.gameObject.activeSelf); + } + + /// + /// Test hook: Gets the text content of the most recently shown message box. + /// + /// Tuple of (title, content, okText, noText), or null if no active boxes. + internal static (string title, string content, string okText, string noText)? TestGetContent() + { + if (ActiveMessageBoxes.Count == 0) return null; + + var target = ActiveMessageBoxes.First(); + return ( + target._title?.text, + target._content?.text, + target._textOk?.text, + target._textNo?.text + ); + } +#endif + private TextMeshProUGUI _content; private TextMeshProUGUI _textNo; private TextMeshProUGUI _textOk; diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/MessageBoxTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/MessageBoxTests.cs index bd12552d..8d15bc3d 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/MessageBoxTests.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/MessageBoxTests.cs @@ -274,5 +274,259 @@ public IEnumerator Show_MultipleCalls_AllReturnFalse_WhenNoPrefab() => UniTask.T }); #endregion + + #region Pool State Tests (using test hooks) + + [UnityTest] + public IEnumerator Show_IncrementsActiveCount_WhenUsingTestHandler() => UniTask.ToCoroutine(async () => + { + // Note: When TestHandler is set, the actual UI is bypassed, + // so ActiveCount won't increase. This test verifies the expected behavior. + MessageBox.TestHandler = (_, _, _, _) => UniTask.FromResult(true); + + var (initialActive, _) = MessageBox.TestGetPoolState(); + await MessageBox.Show("Test", "Content"); + var (finalActive, _) = MessageBox.TestGetPoolState(); + + // With TestHandler, no actual MessageBox is created + Assert.AreEqual(initialActive, finalActive); + }); + + [Test] + public void TestGetPoolState_ReturnsCorrectInitialState() + { + var (activeCount, pooledCount) = MessageBox.TestGetPoolState(); + + Assert.AreEqual(0, activeCount); + Assert.AreEqual(0, pooledCount); + } + + [Test] + public void TestGetPoolState_AfterDispose_ReturnsZero() + { + MessageBox.Dispose(); + + var (activeCount, pooledCount) = MessageBox.TestGetPoolState(); + + Assert.AreEqual(0, activeCount); + Assert.AreEqual(0, pooledCount); + } + + [Test] + public void TestSimulateButtonClick_ReturnsFalse_WhenNoActiveBoxes() + { + bool result = MessageBox.TestSimulateButtonClick(true); + + Assert.IsFalse(result); + } + + [Test] + public void TestGetButtonVisibility_ReturnsNull_WhenNoActiveBoxes() + { + var result = MessageBox.TestGetButtonVisibility(); + + Assert.IsNull(result); + } + + [Test] + public void TestGetContent_ReturnsNull_WhenNoActiveBoxes() + { + var result = MessageBox.TestGetContent(); + + Assert.IsNull(result); + } + + #endregion + + #region Button Visibility Tests + + [UnityTest] + public IEnumerator Show_EmptyOkText_PassesToHandler() => UniTask.ToCoroutine(async () => + { + string receivedOk = "not-empty"; + + MessageBox.TestHandler = (_, _, ok, _) => + { + receivedOk = ok; + return UniTask.FromResult(true); + }; + + await MessageBox.Show("Title", "Content", "", "Cancel"); + + // Empty string is passed through + Assert.AreEqual("", receivedOk); + }); + + [UnityTest] + public IEnumerator Show_EmptyNoText_PassesToHandler() => UniTask.ToCoroutine(async () => + { + string receivedNo = "not-empty"; + + MessageBox.TestHandler = (_, _, _, no) => + { + receivedNo = no; + return UniTask.FromResult(true); + }; + + await MessageBox.Show("Title", "Content", "OK", ""); + + // Empty string is passed through + Assert.AreEqual("", receivedNo); + }); + + [UnityTest] + public IEnumerator Show_NullOkText_PassesToHandler() => UniTask.ToCoroutine(async () => + { + string receivedOk = "not-null"; + + MessageBox.TestHandler = (_, _, ok, _) => + { + receivedOk = ok; + return UniTask.FromResult(true); + }; + + await MessageBox.Show("Title", "Content", null, "Cancel"); + + // Null is passed through + Assert.IsNull(receivedOk); + }); + + [UnityTest] + public IEnumerator Show_NullNoText_PassesToHandler() => UniTask.ToCoroutine(async () => + { + string receivedNo = "not-null"; + + MessageBox.TestHandler = (_, _, _, no) => + { + receivedNo = no; + return UniTask.FromResult(true); + }; + + await MessageBox.Show("Title", "Content", "OK", null); + + // Null is passed through + Assert.IsNull(receivedNo); + }); + + [UnityTest] + public IEnumerator Show_BothButtonsNullOrEmpty_DefaultsToOkInHandler() => UniTask.ToCoroutine(async () => + { + // Note: The safety check for both buttons being empty happens AFTER + // TestHandler is checked, so TestHandler receives the original null values + string receivedOk = "not-null"; + string receivedNo = "not-null"; + + MessageBox.TestHandler = (_, _, ok, no) => + { + receivedOk = ok; + receivedNo = no; + return UniTask.FromResult(true); + }; + + await MessageBox.Show("Title", "Content", null, null); + + // TestHandler receives original null values + Assert.IsNull(receivedOk); + Assert.IsNull(receivedNo); + }); + + #endregion + + #region Null Content Handling Tests + + [UnityTest] + public IEnumerator Show_NullTitle_HandledGracefully() => UniTask.ToCoroutine(async () => + { + string receivedTitle = "not-null"; + + MessageBox.TestHandler = (title, _, _, _) => + { + receivedTitle = title; + return UniTask.FromResult(true); + }; + + bool result = await MessageBox.Show(null, "Content"); + + Assert.IsNull(receivedTitle); + Assert.IsTrue(result); + }); + + [UnityTest] + public IEnumerator Show_NullContent_HandledGracefully() => UniTask.ToCoroutine(async () => + { + string receivedContent = "not-null"; + + MessageBox.TestHandler = (_, content, _, _) => + { + receivedContent = content; + return UniTask.FromResult(true); + }; + + bool result = await MessageBox.Show("Title", null); + + Assert.IsNull(receivedContent); + Assert.IsTrue(result); + }); + + [UnityTest] + public IEnumerator Show_EmptyStrings_HandledGracefully() => UniTask.ToCoroutine(async () => + { + string receivedTitle = null; + string receivedContent = null; + + MessageBox.TestHandler = (title, content, _, _) => + { + receivedTitle = title; + receivedContent = content; + return UniTask.FromResult(true); + }; + + bool result = await MessageBox.Show("", ""); + + Assert.AreEqual("", receivedTitle); + Assert.AreEqual("", receivedContent); + Assert.IsTrue(result); + }); + + #endregion + + #region Concurrent Operations Tests + + [UnityTest] + public IEnumerator Show_MultipleConcurrent_AllComplete() => UniTask.ToCoroutine(async () => + { + int completionCount = 0; + + MessageBox.TestHandler = (_, _, _, _) => + { + completionCount++; + return UniTask.FromResult(true); + }; + + // Show multiple message boxes concurrently + var task1 = MessageBox.Show("Test1", "Content1"); + var task2 = MessageBox.Show("Test2", "Content2"); + var task3 = MessageBox.Show("Test3", "Content3"); + + await UniTask.WhenAll(task1, task2, task3); + + Assert.AreEqual(3, completionCount); + }); + + [UnityTest] + public IEnumerator CloseAll_AfterMultipleShows_ClearsAll() => UniTask.ToCoroutine(async () => + { + MessageBox.TestHandler = (_, _, _, _) => UniTask.FromResult(true); + + await MessageBox.Show("Test1", "Content1"); + await MessageBox.Show("Test2", "Content2"); + + MessageBox.CloseAll(); + + var (activeCount, _) = MessageBox.TestGetPoolState(); + Assert.AreEqual(0, activeCount); + }); + + #endregion } } diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionExecutionContext.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionExecutionContext.cs new file mode 100644 index 00000000..da4b05cf --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionExecutionContext.cs @@ -0,0 +1,503 @@ +// JActionExecutionContext.cs +// Per-execution state for JAction, enabling parallel execution support +// +// Author: JasonXuDeveloper + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace JEngine.Util.Internal +{ + /// + /// Holds the execution state for a single JAction execution. + /// Enables parallel execution by isolating state per-execution. + /// + internal sealed class JActionExecutionContext + { + #region Static Pool + + private static readonly JObjectPool Pool = new( + maxSize: 64, + onReturn: static ctx => ctx.Reset() + ); + + /// + /// Gets a context from the pool or creates a new one. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static JActionExecutionContext Rent() => Pool.Rent(); + + /// + /// Returns a context to the pool. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Return(JActionExecutionContext context) + { + if (context != null) Pool.Return(context); + } + + #endregion + + #region Instance Fields + + // Task snapshot + private readonly List _tasks = new(8); + + // Execution state + private int _currentTaskIndex; + internal bool IsExecuting; + private bool _cancelled; + private bool _blockingMode; + + // Timing + private float _executeStartTime; + private float _timeoutEndTime; + private float _delayEndTime; + private int _delayEndFrame; + private int _repeatCounter; + private float _repeatLastTime; + + // Async state + private JActionAwaitable _pendingAwaitable; + private bool _awaitingAsync; + + // Cancel callbacks (copied from JAction at execution start) + private Action _onCancel; + private Delegate _onCancelDelegate; + private IStateStorage _onCancelState; + + // Continuation for awaitable + internal Action ContinuationCallback; + + #endregion + + #region Properties + + /// + /// Gets whether this execution has been cancelled. + /// + public bool Cancelled => _cancelled; + + #endregion + + #region Initialization + + /// + /// Initializes this context for a new execution. + /// + /// The task list to snapshot. + /// Cancel callback (stateless). + /// Cancel callback delegate (stateful). + /// Cancel callback state. + /// Execution timeout in seconds (0 = no timeout). + /// Whether this is a blocking (sync) execution. + public void Initialize( + List tasks, + Action onCancel, + Delegate onCancelDelegate, + IStateStorage onCancelState, + float timeout, + bool blockingMode) + { + // Snapshot the tasks + _tasks.Clear(); + for (int i = 0; i < tasks.Count; i++) + { + _tasks.Add(tasks[i]); + } + + // Copy cancel callbacks + _onCancel = onCancel; + _onCancelDelegate = onCancelDelegate; + _onCancelState = onCancelState; + + // Initialize execution state + _currentTaskIndex = 0; + IsExecuting = true; + _cancelled = false; + _blockingMode = blockingMode; + _executeStartTime = Time.realtimeSinceStartup; + _timeoutEndTime = timeout > 0f ? _executeStartTime + timeout : 0f; + _awaitingAsync = false; + _pendingAwaitable = default; + _delayEndTime = 0; + _delayEndFrame = 0; + _repeatCounter = 0; + _repeatLastTime = 0; + ContinuationCallback = null; + } + + private void Reset() + { + _tasks.Clear(); + _currentTaskIndex = 0; + IsExecuting = false; + _cancelled = false; + _blockingMode = false; + _onCancel = null; + _onCancelDelegate = null; + _onCancelState = null; + ContinuationCallback = null; + _delayEndTime = 0; + _delayEndFrame = 0; + _repeatCounter = 0; + _repeatLastTime = 0; + _executeStartTime = 0; + _timeoutEndTime = 0; + _awaitingAsync = false; + _pendingAwaitable = default; + } + + #endregion + + #region Execution + + /// + /// Processes one tick of execution. + /// + /// True if execution is complete, false if more ticks needed. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool Tick() + { + if (_cancelled) + { + OnExecutionComplete(); + return true; + } + + // Check timeout (preemptive cancellation) + if (_timeoutEndTime > 0f && Time.realtimeSinceStartup >= _timeoutEndTime) + { + Cancel(); + OnExecutionComplete(); + return true; + } + + if (_currentTaskIndex >= _tasks.Count) + { + OnExecutionComplete(); + return true; + } + + if (ProcessCurrentTask()) + { + _currentTaskIndex++; + } + + if (_currentTaskIndex >= _tasks.Count) + { + OnExecutionComplete(); + return true; + } + + return false; + } + + private bool ProcessCurrentTask() + { + if (_currentTaskIndex >= _tasks.Count) return true; + + var task = _tasks[_currentTaskIndex]; + + return task.Type switch + { + JActionTaskType.Action => ProcessActionTask(task), + JActionTaskType.AsyncFunc => ProcessAsyncFuncTask(task), + JActionTaskType.Delay => ProcessDelayTask(task), + JActionTaskType.DelayFrame => ProcessDelayFrameTask(task), + JActionTaskType.WaitUntil => ProcessWaitUntilTask(task), + JActionTaskType.WaitWhile => ProcessWaitWhileTask(task), + JActionTaskType.RepeatWhile => ProcessRepeatWhileTask(task), + JActionTaskType.RepeatUntil => ProcessRepeatUntilTask(task), + JActionTaskType.Repeat => ProcessRepeatTask(task), + _ => true + }; + } + + private bool ProcessActionTask(JActionTask task) + { + try { task.InvokeAction(); } + catch (Exception e) { Debug.LogException(e); } + return true; + } + + private bool ProcessAsyncFuncTask(JActionTask task) + { + if (!_awaitingAsync) + { + try + { + _pendingAwaitable = task.InvokeAsyncFunc(); + _awaitingAsync = true; + } + catch (Exception e) + { + Debug.LogException(e); + return true; + } + } + + if (_pendingAwaitable.GetAwaiter().IsCompleted) + { + _awaitingAsync = false; + _pendingAwaitable = default; + return true; + } + return false; + } + + private bool ProcessDelayTask(JActionTask task) + { + float currentTime = Time.realtimeSinceStartup; + if (_delayEndTime <= 0) + _delayEndTime = currentTime + task.FloatParam1; + if (currentTime >= _delayEndTime) + { + _delayEndTime = 0; + return true; + } + return false; + } + + private bool ProcessDelayFrameTask(JActionTask task) + { + float currentTime = Time.realtimeSinceStartup; + int currentFrame = Time.frameCount; + + if (_blockingMode) + { + if (_delayEndTime <= 0) + { + float frameTime = Mathf.Max(Time.unscaledDeltaTime, 0.001f); + _delayEndTime = currentTime + (task.IntParam * frameTime); + } + if (currentTime >= _delayEndTime) + { + _delayEndTime = 0; + return true; + } + return false; + } + + if (_delayEndFrame <= 0) + _delayEndFrame = currentFrame + task.IntParam; + if (currentFrame >= _delayEndFrame) + { + _delayEndFrame = 0; + return true; + } + return false; + } + + private bool ProcessWaitUntilTask(JActionTask task) + { + float currentTime = Time.realtimeSinceStartup; + + if (task.FloatParam1 > 0 && _repeatLastTime > 0) + { + if (currentTime - _repeatLastTime < task.FloatParam1) + return false; + } + _repeatLastTime = currentTime; + + if (task.FloatParam2 > 0 && currentTime - _executeStartTime >= task.FloatParam2) + { + _repeatLastTime = 0; + return true; + } + + try + { + if (task.InvokeCondition()) + { + _repeatLastTime = 0; + return true; + } + } + catch (Exception e) + { + Debug.LogException(e); + _repeatLastTime = 0; + return true; + } + return false; + } + + private bool ProcessWaitWhileTask(JActionTask task) + { + float currentTime = Time.realtimeSinceStartup; + + if (task.FloatParam1 > 0 && _repeatLastTime > 0) + { + if (currentTime - _repeatLastTime < task.FloatParam1) + return false; + } + _repeatLastTime = currentTime; + + if (task.FloatParam2 > 0 && currentTime - _executeStartTime >= task.FloatParam2) + { + _repeatLastTime = 0; + return true; + } + + try + { + if (!task.InvokeCondition()) + { + _repeatLastTime = 0; + return true; + } + } + catch (Exception e) + { + Debug.LogException(e); + _repeatLastTime = 0; + return true; + } + return false; + } + + private bool ProcessRepeatWhileTask(JActionTask task) + { + float currentTime = Time.realtimeSinceStartup; + + if (task.FloatParam2 > 0 && currentTime - _executeStartTime >= task.FloatParam2) + { + _repeatLastTime = 0; + return true; + } + + try + { + if (!task.InvokeCondition()) + { + _repeatLastTime = 0; + return true; + } + + if (task.FloatParam1 > 0 && _repeatLastTime > 0) + { + if (currentTime - _repeatLastTime < task.FloatParam1) + return false; + } + + _repeatLastTime = currentTime; + task.InvokeAction(); + } + catch (Exception e) + { + Debug.LogException(e); + _repeatLastTime = 0; + return true; + } + return false; + } + + private bool ProcessRepeatUntilTask(JActionTask task) + { + float currentTime = Time.realtimeSinceStartup; + + if (task.FloatParam2 > 0 && currentTime - _executeStartTime >= task.FloatParam2) + { + _repeatLastTime = 0; + return true; + } + + try + { + if (task.InvokeCondition()) + { + _repeatLastTime = 0; + return true; + } + + if (task.FloatParam1 > 0 && _repeatLastTime > 0) + { + if (currentTime - _repeatLastTime < task.FloatParam1) + return false; + } + + _repeatLastTime = currentTime; + task.InvokeAction(); + } + catch (Exception e) + { + Debug.LogException(e); + _repeatLastTime = 0; + return true; + } + return false; + } + + private bool ProcessRepeatTask(JActionTask task) + { + float currentTime = Time.realtimeSinceStartup; + + if (_repeatCounter >= task.IntParam) + { + _repeatCounter = 0; + _repeatLastTime = 0; + return true; + } + + if (task.FloatParam1 > 0 && _repeatCounter > 0) + { + if (currentTime - _repeatLastTime < task.FloatParam1) + return false; + } + + _repeatLastTime = currentTime; + + try { task.InvokeAction(); } + catch (Exception e) { Debug.LogException(e); } + + _repeatCounter++; + return _repeatCounter >= task.IntParam; + } + + private void OnExecutionComplete() + { + IsExecuting = false; + var continuation = ContinuationCallback; + ContinuationCallback = null; + continuation?.Invoke(); + + // Note: Context is returned to pool by the caller (JAction or awaiter) + // after they've captured the Cancelled state + } + + #endregion + + #region Cancellation + + /// + /// Cancels this execution and invokes the cancel callback. + /// + public void Cancel() + { + if (!IsExecuting) return; + + _cancelled = true; + + try + { + if (_onCancel != null) + { + _onCancel.Invoke(); + } + else if (_onCancelDelegate != null && _onCancelState != null) + { + _onCancelState.InvokeAction(_onCancelDelegate); + } + } + catch (Exception e) + { + Debug.LogException(e); + } + } + + #endregion + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionExecutionContext.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionExecutionContext.cs.meta new file mode 100644 index 00000000..70d32673 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionExecutionContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3f0c9f8bc609c4a72858d2c0621d7f30 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionRunner.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionRunner.cs index 3a3e46b4..b4d41a5a 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionRunner.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionRunner.cs @@ -16,7 +16,7 @@ namespace JEngine.Util.Internal { /// - /// Static runner that drives execution on the main thread. + /// Static runner that drives execution on the main thread. /// /// /// @@ -30,8 +30,8 @@ namespace JEngine.Util.Internal /// internal static class JActionRunner { - private static readonly ConcurrentQueue PendingQueue = new(); - private static readonly List ActiveActions = new(32); + private static readonly ConcurrentQueue PendingQueue = new(); + private static readonly List ActiveContexts = new(32); private static bool _runtimeInitialized; #if UNITY_EDITOR @@ -61,7 +61,7 @@ private static void OnPlayModeStateChanged(PlayModeStateChange state) { // Drain the queue while (PendingQueue.TryDequeue(out _)) { } - ActiveActions.Clear(); + ActiveContexts.Clear(); _runtimeInitialized = false; } } @@ -106,57 +106,57 @@ private static void InsertUpdateSystem(ref PlayerLoopSystem rootLoop) } /// - /// Registers a for main-thread execution. + /// Registers a for main-thread execution. /// - /// The action to register. Null values are ignored. + /// The context to register. Null values are ignored. /// /// Thread-safe and lock-free via ConcurrentQueue. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Register(JAction action) + public static void Register(JActionExecutionContext context) { - if (action == null) return; - PendingQueue.Enqueue(action); + if (context == null) return; + PendingQueue.Enqueue(context); } private static void Update() { // Drain pending queue into active list (single-threaded, no lock needed) - while (PendingQueue.TryDequeue(out var action)) + while (PendingQueue.TryDequeue(out var context)) { - ActiveActions.Add(action); + ActiveContexts.Add(context); } - if (ActiveActions.Count == 0) return; + if (ActiveContexts.Count == 0) return; // Process in reverse to allow safe removal during iteration - for (int i = ActiveActions.Count - 1; i >= 0; i--) + for (int i = ActiveContexts.Count - 1; i >= 0; i--) { - var action = ActiveActions[i]; - if (action == null) + var context = ActiveContexts[i]; + if (context == null) { - ActiveActions.RemoveAt(i); + ActiveContexts.RemoveAt(i); continue; } try { - if (action.Tick()) + if (context.Tick()) { - ActiveActions.RemoveAt(i); + ActiveContexts.RemoveAt(i); } } catch (Exception e) { Debug.LogException(e); - ActiveActions.RemoveAt(i); + ActiveContexts.RemoveAt(i); } } } /// - /// Gets the approximate number of currently active JActions. + /// Gets the approximate number of currently active execution contexts. /// - public static int ActiveCount => ActiveActions.Count + PendingQueue.Count; + public static int ActiveCount => ActiveContexts.Count + PendingQueue.Count; } } diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JAction.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JAction.cs index 04cd64bf..bd87933a 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JAction.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JAction.cs @@ -7,7 +7,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; -using System.Threading.Tasks; +using Cysharp.Threading.Tasks; using JEngine.Util.Internal; using UnityEngine; @@ -30,6 +30,7 @@ namespace JEngine.Util /// Async/await support with /// Time-based delays, frame delays, and conditional waits /// Repeat loops with conditions or fixed counts + /// Parallel execution mode for concurrent async executions /// /// /// @@ -55,9 +56,9 @@ namespace JEngine.Util /// /// Async/await pattern with auto-dispose: /// - /// async Task MyAsyncMethod() + /// async UniTask MyAsyncMethod() /// { - /// using var action = await JAction.Create() + /// using var result = await JAction.Create() /// .Do(() => Debug.Log("Start")) /// .Delay(1f) /// .Do(() => Debug.Log("End")) @@ -80,12 +81,26 @@ namespace JEngine.Util /// /// Manual disposal (without using keyword): /// - /// var action = JAction.Create() + /// var result = JAction.Create() /// .Do(() => Debug.Log("Task")) /// .Execute(); /// /// // When done, dispose to return to pool - /// action.Dispose(); + /// result.Dispose(); + /// + /// + /// Parallel execution: + /// + /// var action = JAction.Create() + /// .Parallel() + /// .Do(() => Debug.Log("Start")) + /// .DelayFrame(5) + /// .Do(() => Debug.Log("End")); + /// + /// // Both run concurrently with separate execution contexts + /// var task1 = action.ExecuteAsync(); + /// var task2 = action.ExecuteAsync(); + /// await UniTask.WhenAll(task1.AsUniTask(), task2.AsUniTask()); /// /// public sealed class JAction : IDisposable @@ -108,44 +123,50 @@ public sealed class JAction : IDisposable #region Instance Fields + // Task list for building the action chain private readonly List _tasks; - private int _currentTaskIndex; - internal bool IsExecuting; - private bool _cancelled; + + // Configuration private bool _parallel; private bool _disposed; - private bool _blockingMode; + // Cancel callbacks (used when creating execution contexts) private Action _onCancel; private Delegate _onCancelDelegate; private IStateStorage _onCancelState; - internal Action ContinuationCallback; - private float _executeStartTime; - private float _timeoutEndTime; - - private float _delayEndTime; - private int _delayEndFrame; - private int _repeatCounter; - private float _repeatLastTime; + // Active execution contexts (for Cancel() support) + private readonly List _activeContexts = new(4); - // Async task state - private JActionAwaitable _pendingAwaitable; - private bool _awaitingAsync; + // Synchronous execution state (blocking mode doesn't use contexts) + private JActionExecutionContext _syncContext; #endregion #region Properties /// - /// Gets whether this JAction is currently executing. + /// Gets whether this JAction has any active executions. /// - public bool Executing => IsExecuting; + public bool Executing => _activeContexts.Count > 0 || (_syncContext != null && _syncContext.IsExecuting); /// - /// Gets whether this JAction has been cancelled. + /// Gets whether any active execution is cancelled. + /// For per-execution cancellation state, use the + /// property from the result of . /// - public bool Cancelled => _cancelled; + public bool Cancelled + { + get + { + if (_syncContext != null) return _syncContext.Cancelled; + for (int i = 0; i < _activeContexts.Count; i++) + { + if (_activeContexts[i].Cancelled) return true; + } + return false; + } + } /// /// Gets whether parallel execution mode is enabled. @@ -179,7 +200,7 @@ public JAction() /// A JAction instance from the pool or newly created. /// /// - /// var action = JAction.Create() + /// var result = JAction.Create() /// .Do(() => Debug.Log("Hello")) /// .Execute(); /// @@ -637,6 +658,11 @@ public JAction Repeat(Action action, TState state, int count, fl /// Enables parallel execution mode, allowing multiple concurrent executions. /// /// This JAction for method chaining. + /// + /// When parallel mode is enabled, each call to creates + /// a separate execution context, allowing multiple concurrent executions of the + /// same action chain. + /// public JAction Parallel() { _parallel = true; @@ -644,7 +670,7 @@ public JAction Parallel() } /// - /// Registers a callback to be invoked when this JAction is cancelled. + /// Registers a callback to be invoked when an execution is cancelled. /// /// The callback to invoke on cancellation. /// This JAction for method chaining. @@ -652,11 +678,11 @@ public JAction Parallel() /// /// var action = JAction.Create() /// .Delay(10f) - /// .OnCancel(() => Debug.Log("Cancelled!")) - /// .Execute(); + /// .OnCancel(() => Debug.Log("Cancelled!")); /// + /// var handle = action.ExecuteAsync(); /// // Later... - /// action.Cancel(); // Triggers the OnCancel callback + /// handle.Cancel(); // Triggers the OnCancel callback /// /// public JAction OnCancel(Action callback) @@ -695,7 +721,7 @@ public JAction OnCancel(Action callback, TState state) /// Maximum time in seconds to wait before cancelling. /// Use 0 or negative for no timeout (default). /// - /// This JAction for method chaining or status checking. + /// A containing the execution result. /// /// /// This method blocks until all tasks complete or timeout is reached. @@ -710,41 +736,44 @@ public JAction OnCancel(Action callback, TState state) /// /// /// // Blocking execution with 5 second timeout - /// var action = JAction.Create() + /// using var result = JAction.Create() /// .Do(() => Debug.Log("Step 1")) /// .Delay(0.5f) /// .Do(() => Debug.Log("Step 2")) /// .Execute(timeout: 5f); /// - /// if (action.Cancelled) + /// if (result.Cancelled) /// Debug.Log("Action timed out!"); /// /// - public JAction Execute(float timeout = 0f) + public JActionExecution Execute(float timeout = 0f) { - if (IsExecuting && !_parallel) + if (Executing && !_parallel) { Debug.LogWarning("[JAction] Already executing. Enable Parallel() for concurrent execution."); - return this; + return new JActionExecution(this, false); } - if (_tasks.Count == 0) return this; + if (_tasks.Count == 0) return new JActionExecution(this, false); - IsExecuting = true; - _cancelled = false; - _currentTaskIndex = 0; - _executeStartTime = Time.realtimeSinceStartup; - _blockingMode = true; - _timeoutEndTime = timeout > 0f ? _executeStartTime + timeout : 0f; - _awaitingAsync = false; + // Create execution context for synchronous execution + _syncContext = JActionExecutionContext.Rent(); + _syncContext.Initialize(_tasks, _onCancel, _onCancelDelegate, _onCancelState, timeout, blockingMode: true); - // Spin until complete (timeout checked in Tick) - while (!Tick()) + // Spin until complete + while (!_syncContext.Tick()) { // Intentionally empty - Tick() advances state each iteration } - return this; + // Capture cancelled state before returning context to pool + bool cancelled = _syncContext.Cancelled; + + // Return context to pool + JActionExecutionContext.Return(_syncContext); + _syncContext = null; + + return new JActionExecution(this, cancelled); } /// @@ -753,7 +782,7 @@ public JAction Execute(float timeout = 0f) /// /// Maximum time in seconds before cancelling. Use 0 or negative for no timeout (default). /// - /// This JAction after execution completes, enabling the using pattern. + /// A that can be awaited or cancelled. /// /// /// Unlike , this method returns immediately and processes @@ -761,355 +790,67 @@ public JAction Execute(float timeout = 0f) /// that require actual Unity frames to advance. /// /// - /// Important: Always await this method and use the using pattern - /// to ensure proper cleanup and pool return. + /// When mode is enabled, each call creates a separate + /// execution context. Each returned handle can be cancelled independently. /// /// /// /// - /// async Task PlaySequenceAsync() + /// async UniTask PlaySequenceAsync() /// { - /// using var action = await JAction.Create() + /// using var result = await JAction.Create() /// .Do(() => Debug.Log("Step 1")) /// .Delay(1f) /// .Do(() => Debug.Log("Step 2")) /// .ExecuteAsync(timeout: 5f); + /// + /// if (result.Cancelled) + /// Debug.Log("Execution was cancelled!"); /// } + /// + /// // Cancelling specific executions in parallel mode: + /// var action = JAction.Create().Parallel().Delay(5f); + /// var handle1 = action.ExecuteAsync(); + /// var handle2 = action.ExecuteAsync(); + /// handle1.Cancel(); // Cancel only the first execution + /// var result1 = await handle1; // result1.Cancelled == true + /// var result2 = await handle2; // result2.Cancelled == false /// /// - public async ValueTask ExecuteAsync(float timeout = 0f) - { - StartAsync(timeout); - await new JActionAwaitable(this); - return this; - } - - private void StartAsync(float timeout) + public JActionExecutionHandle ExecuteAsync(float timeout = 0f) { - if (IsExecuting && !_parallel) + if (!_parallel && Executing) { Debug.LogWarning("[JAction] Already executing. Enable Parallel() for concurrent execution."); - return; - } - - if (_tasks.Count == 0) return; - - IsExecuting = true; - _cancelled = false; - _currentTaskIndex = 0; - _executeStartTime = Time.realtimeSinceStartup; - _blockingMode = false; - _timeoutEndTime = timeout > 0f ? _executeStartTime + timeout : 0f; - _awaitingAsync = false; - - JActionRunner.Register(this); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal bool Tick() - { - if (_cancelled) - { - OnExecutionComplete(); - return true; - } - - // Check timeout (preemptive cancellation) - if (_timeoutEndTime > 0f && Time.realtimeSinceStartup >= _timeoutEndTime) - { - Cancel(); - OnExecutionComplete(); - return true; - } - - if (_currentTaskIndex >= _tasks.Count) - { - OnExecutionComplete(); - return true; + return new JActionExecutionHandle(this, null); } - if (ProcessCurrentTask()) - { - _currentTaskIndex++; - } - - if (_currentTaskIndex >= _tasks.Count) - { - OnExecutionComplete(); - return true; - } - - return false; - } - - private bool ProcessCurrentTask() - { - if (_currentTaskIndex >= _tasks.Count) return true; - - var task = _tasks[_currentTaskIndex]; - - return task.Type switch - { - JActionTaskType.Action => ProcessActionTask(task), - JActionTaskType.AsyncFunc => ProcessAsyncFuncTask(task), - JActionTaskType.Delay => ProcessDelayTask(task), - JActionTaskType.DelayFrame => ProcessDelayFrameTask(task), - JActionTaskType.WaitUntil => ProcessWaitUntilTask(task), - JActionTaskType.WaitWhile => ProcessWaitWhileTask(task), - JActionTaskType.RepeatWhile => ProcessRepeatWhileTask(task), - JActionTaskType.RepeatUntil => ProcessRepeatUntilTask(task), - JActionTaskType.Repeat => ProcessRepeatTask(task), - _ => true - }; - } - - private bool ProcessActionTask(JActionTask task) - { - try { task.InvokeAction(); } - catch (Exception e) { Debug.LogException(e); } - return true; - } - - private bool ProcessAsyncFuncTask(JActionTask task) - { - if (!_awaitingAsync) - { - try - { - _pendingAwaitable = task.InvokeAsyncFunc(); - _awaitingAsync = true; - } - catch (Exception e) - { - Debug.LogException(e); - return true; - } - } + if (_tasks.Count == 0) return new JActionExecutionHandle(this, null); - if (_pendingAwaitable.GetAwaiter().IsCompleted) - { - _awaitingAsync = false; - _pendingAwaitable = default; - return true; - } - return false; - } + // Create execution context + var context = JActionExecutionContext.Rent(); + context.Initialize(_tasks, _onCancel, _onCancelDelegate, _onCancelState, timeout, blockingMode: false); - private bool ProcessDelayTask(JActionTask task) - { - float currentTime = Time.realtimeSinceStartup; - if (_delayEndTime <= 0) - _delayEndTime = currentTime + task.FloatParam1; - if (currentTime >= _delayEndTime) - { - _delayEndTime = 0; - return true; - } - return false; - } + // Track context for Cancel() support + _activeContexts.Add(context); - private bool ProcessDelayFrameTask(JActionTask task) - { - float currentTime = Time.realtimeSinceStartup; - int currentFrame = Time.frameCount; + // Register with runner + JActionRunner.Register(context); - if (_blockingMode) - { - if (_delayEndTime <= 0) - { - float frameTime = Mathf.Max(Time.unscaledDeltaTime, 0.001f); - _delayEndTime = currentTime + (task.IntParam * frameTime); - } - if (currentTime >= _delayEndTime) - { - _delayEndTime = 0; - return true; - } - return false; - } - - if (_delayEndFrame <= 0) - _delayEndFrame = currentFrame + task.IntParam; - if (currentFrame >= _delayEndFrame) - { - _delayEndFrame = 0; - return true; - } - return false; + // Return handle that can be awaited or cancelled + return new JActionExecutionHandle(this, context); } - private bool ProcessWaitUntilTask(JActionTask task) - { - float currentTime = Time.realtimeSinceStartup; - - if (task.FloatParam1 > 0 && _repeatLastTime > 0) - { - if (currentTime - _repeatLastTime < task.FloatParam1) - return false; - } - _repeatLastTime = currentTime; - - if (task.FloatParam2 > 0 && currentTime - _executeStartTime >= task.FloatParam2) - { - _repeatLastTime = 0; - return true; - } - - try - { - if (task.InvokeCondition()) - { - _repeatLastTime = 0; - return true; - } - } - catch (Exception e) - { - Debug.LogException(e); - _repeatLastTime = 0; - return true; - } - return false; - } - - private bool ProcessWaitWhileTask(JActionTask task) - { - float currentTime = Time.realtimeSinceStartup; - - if (task.FloatParam1 > 0 && _repeatLastTime > 0) - { - if (currentTime - _repeatLastTime < task.FloatParam1) - return false; - } - _repeatLastTime = currentTime; - - if (task.FloatParam2 > 0 && currentTime - _executeStartTime >= task.FloatParam2) - { - _repeatLastTime = 0; - return true; - } - - try - { - if (!task.InvokeCondition()) - { - _repeatLastTime = 0; - return true; - } - } - catch (Exception e) - { - Debug.LogException(e); - _repeatLastTime = 0; - return true; - } - return false; - } - - private bool ProcessRepeatWhileTask(JActionTask task) - { - float currentTime = Time.realtimeSinceStartup; - - if (task.FloatParam2 > 0 && currentTime - _executeStartTime >= task.FloatParam2) - { - _repeatLastTime = 0; - return true; - } - - try - { - if (!task.InvokeCondition()) - { - _repeatLastTime = 0; - return true; - } - - if (task.FloatParam1 > 0 && _repeatLastTime > 0) - { - if (currentTime - _repeatLastTime < task.FloatParam1) - return false; - } - - _repeatLastTime = currentTime; - task.InvokeAction(); - } - catch (Exception e) - { - Debug.LogException(e); - _repeatLastTime = 0; - return true; - } - return false; - } - - private bool ProcessRepeatUntilTask(JActionTask task) - { - float currentTime = Time.realtimeSinceStartup; - - if (task.FloatParam2 > 0 && currentTime - _executeStartTime >= task.FloatParam2) - { - _repeatLastTime = 0; - return true; - } - - try - { - if (task.InvokeCondition()) - { - _repeatLastTime = 0; - return true; - } - - if (task.FloatParam1 > 0 && _repeatLastTime > 0) - { - if (currentTime - _repeatLastTime < task.FloatParam1) - return false; - } - - _repeatLastTime = currentTime; - task.InvokeAction(); - } - catch (Exception e) - { - Debug.LogException(e); - _repeatLastTime = 0; - return true; - } - return false; - } - - private bool ProcessRepeatTask(JActionTask task) + /// + /// Removes an execution context from the active list. + /// Called internally when an execution completes. + /// + internal void RemoveActiveContext(JActionExecutionContext context) { - float currentTime = Time.realtimeSinceStartup; - - if (_repeatCounter >= task.IntParam) + if (context != null) { - _repeatCounter = 0; - _repeatLastTime = 0; - return true; + _activeContexts.Remove(context); } - - if (task.FloatParam1 > 0 && _repeatCounter > 0) - { - if (currentTime - _repeatLastTime < task.FloatParam1) - return false; - } - - _repeatLastTime = currentTime; - - try { task.InvokeAction(); } - catch (Exception e) { Debug.LogException(e); } - - _repeatCounter++; - return _repeatCounter >= task.IntParam; - } - - private void OnExecutionComplete() - { - IsExecuting = false; - var continuation = ContinuationCallback; - ContinuationCallback = null; - continuation?.Invoke(); } #endregion @@ -1117,29 +858,18 @@ private void OnExecutionComplete() #region Cancellation /// - /// Cancels the current execution and invokes the OnCancel callback if set. + /// Cancels all active executions and invokes OnCancel callbacks. /// /// This JAction for method chaining. public JAction Cancel() { - if (!IsExecuting) return this; - - _cancelled = true; + // Cancel sync context if active + _syncContext?.Cancel(); - try - { - if (_onCancel != null) - { - _onCancel.Invoke(); - } - else if (_onCancelDelegate != null && _onCancelState != null) - { - _onCancelState.InvokeAction(_onCancelDelegate); - } - } - catch (Exception e) + // Cancel all async contexts + for (int i = _activeContexts.Count - 1; i >= 0; i--) { - Debug.LogException(e); + _activeContexts[i].Cancel(); } return this; @@ -1156,12 +886,18 @@ public JAction Cancel() /// This JAction for method chaining. public JAction Reset(bool force = false) { - if (IsExecuting && !force) + if (Executing && !force) { Debug.LogWarning("[JAction] Cannot reset while executing. Use Reset(true) to force."); return this; } + // Cancel any active executions + if (force) + { + Cancel(); + } + // Return state storage to pools before clearing for (int i = 0; i < _tasks.Count; i++) { @@ -1169,24 +905,25 @@ public JAction Reset(bool force = false) } _tasks.Clear(); - _currentTaskIndex = 0; - IsExecuting = false; - _cancelled = false; _parallel = false; _onCancel = null; _onCancelDelegate = null; _onCancelState?.Return(); _onCancelState = null; - ContinuationCallback = null; - _delayEndTime = 0; - _delayEndFrame = 0; - _repeatCounter = 0; - _repeatLastTime = 0; - _executeStartTime = 0; - _timeoutEndTime = 0; - _blockingMode = false; - _awaitingAsync = false; - _pendingAwaitable = default; + + // Return active execution contexts to pool before clearing + for (int i = 0; i < _activeContexts.Count; i++) + { + JActionExecutionContext.Return(_activeContexts[i]); + } + _activeContexts.Clear(); + + // Return sync execution context to pool + if (_syncContext != null) + { + JActionExecutionContext.Return(_syncContext); + _syncContext = null; + } return this; } @@ -1202,7 +939,7 @@ public void Dispose() { if (_disposed) return; - if (IsExecuting) + if (Executing) { Cancel(); } diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionAwaitable.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionAwaitable.cs index ee9ce48f..c8b4e2db 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionAwaitable.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionAwaitable.cs @@ -5,6 +5,7 @@ using System; using System.Runtime.CompilerServices; +using JEngine.Util.Internal; namespace JEngine.Util { @@ -23,22 +24,22 @@ namespace JEngine.Util /// public readonly struct JActionAwaitable { - /// The JAction instance being awaited. - public readonly JAction Action; + /// The execution context being awaited. + internal readonly JActionExecutionContext Context; /// /// Initializes a new instance of the struct. /// - /// The JAction to await. + /// The execution context to await. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public JActionAwaitable(JAction action) => Action = action; + internal JActionAwaitable(JActionExecutionContext context) => Context = context; /// /// Gets the awaiter for this awaitable. /// /// A instance. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public JActionAwaiter GetAwaiter() => new(Action); + public JActionAwaiter GetAwaiter() => new(Context); } /// @@ -51,27 +52,27 @@ public readonly struct JActionAwaitable /// public readonly struct JActionAwaiter : ICriticalNotifyCompletion { - /// The JAction instance being awaited. - public readonly JAction Action; + /// The execution context being awaited. + internal readonly JActionExecutionContext Context; /// /// Initializes a new instance of the struct. /// - /// The JAction to await. + /// The execution context to await. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public JActionAwaiter(JAction action) => Action = action; + internal JActionAwaiter(JActionExecutionContext context) => Context = context; /// - /// Gets whether the has completed execution. + /// Gets whether the execution has completed. /// /// - /// true if the action is null, disposed, or has finished executing; + /// true if the context is null or has finished executing; /// false if still in progress. /// public bool IsCompleted { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => Action == null || !Action.IsExecuting; + get => Context == null || !Context.IsExecuting; } /// @@ -94,12 +95,12 @@ public void GetResult() [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted(Action continuation) { - if (Action == null || !Action.IsExecuting) + if (Context == null || !Context.IsExecuting) { continuation?.Invoke(); return; } - Action.ContinuationCallback = continuation; + Context.ContinuationCallback = continuation; } /// diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecution.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecution.cs new file mode 100644 index 00000000..382dad92 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecution.cs @@ -0,0 +1,71 @@ +// JActionExecution.cs +// Result struct for JAction execution with per-execution state +// +// Author: JasonXuDeveloper + +using System; +using System.Runtime.CompilerServices; + +namespace JEngine.Util +{ + /// + /// Represents the result of a JAction execution. + /// Captures per-execution state (like cancellation) that is specific to one execution, + /// even when the same JAction is executed multiple times in parallel. + /// + public readonly struct JActionExecution : IDisposable + { + /// + /// The JAction that was executed. + /// + public JAction Action + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + } + + /// + /// Gets whether this specific execution was cancelled. + /// + public bool Cancelled + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + } + + /// + /// Gets whether the action is still executing. + /// Note: This reflects the JAction's overall state, not this specific execution. + /// + public bool Executing + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Action?.Executing ?? false; + } + + /// + /// Creates a new JActionExecution result. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal JActionExecution(JAction action, bool cancelled) + { + Action = action; + Cancelled = cancelled; + } + + /// + /// Disposes the underlying JAction, returning it to the pool. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + Action?.Dispose(); + } + + /// + /// Implicit conversion to JAction for backwards compatibility. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static implicit operator JAction(JActionExecution execution) => execution.Action; + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecution.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecution.cs.meta new file mode 100644 index 00000000..09cc64ad --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecution.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c51c36c17f6145879435585780588eb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs new file mode 100644 index 00000000..d06ba7bd --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs @@ -0,0 +1,169 @@ +// JActionExecutionHandle.cs +// Handle for controlling a specific JAction execution +// +// Author: JasonXuDeveloper + +using System; +using System.Runtime.CompilerServices; +using Cysharp.Threading.Tasks; +using JEngine.Util.Internal; + +namespace JEngine.Util +{ + /// + /// Handle for controlling a specific JAction execution. + /// Allows cancelling individual executions when parallel mode is enabled. + /// + /// + /// This struct is returned by and can be: + /// + /// Awaited to get the result + /// Cancelled via to stop this specific execution + /// Disposed to clean up the underlying JAction + /// + /// + /// + /// + /// var action = JAction.Create().Parallel().Delay(5f).Do(() => Debug.Log("Done")); + /// + /// // Start multiple executions + /// var handle1 = action.ExecuteAsync(); + /// var handle2 = action.ExecuteAsync(); + /// + /// // Cancel only the first one + /// handle1.Cancel(); + /// + /// // Await both + /// var result1 = await handle1; // result1.Cancelled == true + /// var result2 = await handle2; // result2.Cancelled == false + /// + /// + public readonly struct JActionExecutionHandle : IDisposable + { + private readonly JAction _action; + private readonly JActionExecutionContext _context; + + /// + /// Gets the JAction associated with this execution. + /// + public JAction Action => _action; + + /// + /// Gets whether this execution has been cancelled. + /// + public bool Cancelled => _context?.Cancelled ?? false; + + /// + /// Gets whether this execution is still running. + /// + public bool Executing => _context?.IsExecuting ?? false; + + /// + /// Creates a new execution handle. + /// + internal JActionExecutionHandle(JAction action, JActionExecutionContext context) + { + _action = action; + _context = context; + } + + /// + /// Cancels this specific execution. + /// Does not affect other parallel executions of the same JAction. + /// + public void Cancel() + { + _context?.Cancel(); + } + + /// + /// Gets the awaiter for this handle. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public JActionExecutionAwaiter GetAwaiter() => new(_action, _context); + + /// + /// Converts this handle to a UniTask for advanced async operations. + /// + public async UniTask AsUniTask() + { + // Delegate to the handle's awaiter so that completion and cleanup + // are managed in a single, centralized location. + return await this; + } + + /// + /// Disposes the underlying JAction, returning it to the pool. + /// + public void Dispose() + { + _action?.Dispose(); + } + + /// + /// Implicit conversion to JAction for backwards compatibility. + /// + public static implicit operator JAction(JActionExecutionHandle handle) => handle._action; + } + + /// + /// Awaiter for . + /// + public readonly struct JActionExecutionAwaiter : ICriticalNotifyCompletion + { + private readonly JAction _action; + private readonly JActionExecutionContext _context; + + internal JActionExecutionAwaiter(JAction action, JActionExecutionContext context) + { + _action = action; + _context = context; + } + + /// + /// Gets whether the execution has completed. + /// + public bool IsCompleted + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _context == null || !_context.IsExecuting; + } + + /// + /// Gets the result of the execution. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public JActionExecution GetResult() + { + // Remove context from action's active list + _action?.RemoveActiveContext(_context); + + // Capture cancelled state before returning context to pool + bool cancelled = _context?.Cancelled ?? false; + + // Return context to pool + JActionExecutionContext.Return(_context); + + return new JActionExecution(_action, cancelled); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnCompleted(Action continuation) + { + if (_context == null || !_context.IsExecuting) + { + continuation?.Invoke(); + return; + } + _context.ContinuationCallback = continuation; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void UnsafeOnCompleted(Action continuation) + { + OnCompleted(continuation); + } + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs.meta new file mode 100644 index 00000000..69987132 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20297a0d83f59464f9bf96dd7e2f8a19 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JEngine.Util.asmdef b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JEngine.Util.asmdef index 3945d62c..0dd1a7e6 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JEngine.Util.asmdef +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JEngine.Util.asmdef @@ -1,7 +1,9 @@ { "name": "JEngine.Util", "rootNamespace": "JEngine.Util", - "references": [], + "references": [ + "UniTask" + ], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, @@ -11,4 +13,4 @@ "defineConstraints": [], "versionDefines": [], "noEngineReferences": false -} +} \ No newline at end of file diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JActionTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JActionTests.cs index 27e7df25..077afb47 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JActionTests.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JActionTests.cs @@ -1,12 +1,221 @@ // JActionTests.cs // EditMode unit tests for JAction (synchronous execution) +using System; using System.Collections.Generic; -using System.Reflection; using NUnit.Framework; namespace JEngine.Util.Tests { + #region JObjectPool Tests + + [TestFixture] + public class JObjectPoolTests + { + private class TestPoolItem + { + public int Value; + public bool WasRented; + public bool WasReturned; + } + + [Test] + public void Shared_ReturnsSameInstanceForType() + { + var pool1 = JObjectPool.Shared(); + var pool2 = JObjectPool.Shared(); + + Assert.AreSame(pool1, pool2); + } + + [Test] + public void Shared_IsolatesBetweenTypes() + { + var pool1 = JObjectPool.Shared(); + var pool2 = JObjectPool.Shared>(); + + // Can't directly compare different generic types, but we can verify + // they maintain separate state by checking they're both functional + Assert.IsNotNull(pool1); + Assert.IsNotNull(pool2); + + // Rent from each - they should not interfere + var item1 = pool1.Rent(); + var item2 = pool2.Rent(); + + Assert.IsInstanceOf(item1); + Assert.IsInstanceOf>(item2); + + pool1.Return(item1); + pool2.Return(item2); + } + + [Test] + public void Prewarm_CreatesSpecifiedCount() + { + var pool = new JObjectPool(maxSize: 10); + + Assert.AreEqual(0, pool.Count); + + pool.Prewarm(5); + + Assert.AreEqual(5, pool.Count); + } + + [Test] + public void Prewarm_RespectsMaxSize() + { + var pool = new JObjectPool(maxSize: 3); + + pool.Prewarm(10); // Request more than max + + Assert.AreEqual(3, pool.Count); // Should cap at maxSize + } + + [Test] + public void Return_IgnoresNull() + { + var pool = new JObjectPool(maxSize: 10); + + Assert.DoesNotThrow(() => pool.Return(null)); + Assert.AreEqual(0, pool.Count); + } + + [Test] + public void Return_DiscardsWhenAtCapacity() + { + var pool = new JObjectPool(maxSize: 2); + + pool.Prewarm(2); // Fill to capacity + Assert.AreEqual(2, pool.Count); + + var extraItem = new TestPoolItem(); + pool.Return(extraItem); // Should be discarded + + Assert.AreEqual(2, pool.Count); // Still at capacity + } + + [Test] + public void OnRent_CallbackInvoked() + { + int rentCallCount = 0; + var pool = new JObjectPool( + maxSize: 10, + onRent: item => + { + rentCallCount++; + item.WasRented = true; + } + ); + + var item = pool.Rent(); + + Assert.AreEqual(1, rentCallCount); + Assert.IsTrue(item.WasRented); + + pool.Return(item); + } + + [Test] + public void OnReturn_CallbackInvoked() + { + int returnCallCount = 0; + var pool = new JObjectPool( + maxSize: 10, + onReturn: item => + { + returnCallCount++; + item.WasReturned = true; + } + ); + + var item = pool.Rent(); + Assert.IsFalse(item.WasReturned); + + pool.Return(item); + + Assert.AreEqual(1, returnCallCount); + Assert.IsTrue(item.WasReturned); + } + + [Test] + public void Rent_CreatesNewInstance_WhenPoolEmpty() + { + var pool = new JObjectPool(maxSize: 10); + + Assert.AreEqual(0, pool.Count); + + var item = pool.Rent(); + + Assert.IsNotNull(item); + Assert.IsInstanceOf(item); + } + + [Test] + public void Rent_ReusesPooledInstance() + { + var pool = new JObjectPool(maxSize: 10); + + var item1 = pool.Rent(); + item1.Value = 42; + pool.Return(item1); + + var item2 = pool.Rent(); + + Assert.AreSame(item1, item2); + Assert.AreEqual(42, item2.Value); // Value preserved + } + + [Test] + public void Clear_RemovesAllItems() + { + var pool = new JObjectPool(maxSize: 10); + + pool.Prewarm(5); + Assert.AreEqual(5, pool.Count); + + pool.Clear(); + + Assert.AreEqual(0, pool.Count); + } + + [Test] + public void Count_ReflectsPoolState() + { + var pool = new JObjectPool(maxSize: 10); + + Assert.AreEqual(0, pool.Count); + + var item1 = pool.Rent(); + Assert.AreEqual(0, pool.Count); // Rented, not in pool + + pool.Return(item1); + Assert.AreEqual(1, pool.Count); + + var item2 = pool.Rent(); + Assert.AreEqual(0, pool.Count); // Rented again + + pool.Return(item2); + Assert.AreEqual(1, pool.Count); + } + + [Test] + public void Constructor_WithDefaultMaxSize_Uses64() + { + var pool = new JObjectPool(); + + // Fill beyond default size + pool.Prewarm(100); + + // Default maxSize is 64 + Assert.AreEqual(64, pool.Count); + } + } + + #endregion + + #region JAction Tests + [TestFixture] public class JActionTests { @@ -142,28 +351,18 @@ public void Repeat_WithState_PassesStateEachTime() #region Cancel Tests [Test] - public void Cancel_InvokesOnCancelCallback() + public void Cancel_InvokesOnCancelCallback_WhenTimeoutExceeded() { bool cancelled = false; + // Use timeout to trigger cancellation using var action = JAction.Create() - .Do(() => { }) - .OnCancel(() => cancelled = true); - - action.Execute(); - - cancelled = false; - using var action2 = JAction.Create() - .OnCancel(() => cancelled = true); - - // Force executing state and cancel - typeof(JAction).GetField("IsExecuting", - BindingFlags.NonPublic | - BindingFlags.Instance) - ?.SetValue(action2, true); + .Delay(1f) // Long delay + .OnCancel(() => cancelled = true) + .Execute(timeout: 0.01f); // Short timeout triggers cancel - action2.Cancel(); Assert.IsTrue(cancelled); + Assert.IsTrue(action.Cancelled); } [Test] @@ -171,18 +370,28 @@ public void Cancel_WithState_PassesStateToCallback() { int result = 0; + // Use timeout to trigger cancellation with state callback using var action = JAction.Create() - .OnCancel(x => result = x, 42); + .Delay(1f) // Long delay + .OnCancel(x => result = x, 42) + .Execute(timeout: 0.01f); // Short timeout triggers cancel + + Assert.AreEqual(42, result); + } + + [Test] + public void Cancel_NotExecuting_DoesNothing() + { + bool cancelled = false; - // Force executing state - typeof(JAction).GetField("IsExecuting", - BindingFlags.NonPublic | - BindingFlags.Instance) - ?.SetValue(action, true); + using var action = JAction.Create() + .Do(() => { }) + .OnCancel(() => cancelled = true); + // Cancel without executing - should not invoke callback action.Cancel(); - Assert.AreEqual(42, result); + Assert.IsFalse(cancelled); } #endregion @@ -264,10 +473,10 @@ public void Reset_AllowsReuse() { int counter = 0; - using var action = JAction.Create() - .Do(() => counter++) - .Execute(); + var action = JAction.Create() + .Do(() => counter++); + action.Execute(); Assert.AreEqual(1, counter); action.Reset(); @@ -275,6 +484,8 @@ public void Reset_AllowsReuse() .Execute(); Assert.AreEqual(11, counter); + + action.Dispose(); } #endregion @@ -333,40 +544,219 @@ public void Execute_EmptyAction_CompletesImmediately() } [Test] - public void Dispose_DuringExecution_CancelsFirst() + public void Dispose_CanBeCalledMultipleTimes() { - bool cancelled = false; + var action = JAction.Create() + .Do(() => { }); - using var action = JAction.Create() - .Delay(1f) - .OnCancel(() => cancelled = true); + // Multiple dispose calls should not throw + Assert.DoesNotThrow(() => + { + action.Dispose(); + action.Dispose(); + action.Dispose(); + }); + } - // Start execution - typeof(JAction).GetField("IsExecuting", - BindingFlags.NonPublic | - BindingFlags.Instance) - ?.SetValue(action, true); + [Test] + public void Execute_WithNullAction_SkipsGracefully() + { + int counter = 0; - // Dispose is called by using statement, which will cancel first - // We need to manually trigger for the assertion - action.Dispose(); + JAction.Create() + .Do(null) + .Do(() => counter++) + .Execute(); - Assert.IsTrue(cancelled); + Assert.AreEqual(1, counter); } [Test] - public void Execute_WithNullAction_SkipsGracefully() + public void Delay_ZeroValue_SkipsDelay() { int counter = 0; JAction.Create() - .Do(null) + .Delay(0f) // Zero delay should be skipped .Do(() => counter++) .Execute(); Assert.AreEqual(1, counter); } + [Test] + public void Delay_NegativeValue_SkipsDelay() + { + int counter = 0; + + JAction.Create() + .Delay(-1f) // Negative delay should be skipped + .Do(() => counter++) + .Execute(); + + Assert.AreEqual(1, counter); + } + + [Test] + public void DelayFrame_ZeroValue_SkipsDelay() + { + int counter = 0; + + JAction.Create() + .DelayFrame(0) // Zero frames should be skipped + .Do(() => counter++) + .Execute(); + + Assert.AreEqual(1, counter); + } + + [Test] + public void DelayFrame_NegativeValue_SkipsDelay() + { + int counter = 0; + + JAction.Create() + .DelayFrame(-1) // Negative frames should be skipped + .Do(() => counter++) + .Execute(); + + Assert.AreEqual(1, counter); + } + + [Test] + public void TaskCapacity_ThrowsWhenExceeded() + { + using var action = JAction.Create(); + + // Add 256 tasks (the max capacity) + for (int i = 0; i < 256; i++) + { + action.Do(() => { }); + } + + // The 257th task should throw + Assert.Throws(() => action.Do(() => { })); + } + + [Test] + public void Reset_ClearsTasks() + { + int counter = 0; + + using var action = JAction.Create() + .Do(() => counter++) + .Do(() => counter++); + + // Reset should clear the tasks + action.Reset(); + + // Execute after reset - should do nothing (no tasks) + action.Execute(); + + Assert.AreEqual(0, counter); + } + + [Test] + public void Reset_ClearsParallelMode() + { + using var action = JAction.Create() + .Parallel(); + + Assert.IsTrue(action.IsParallel); + + action.Reset(); + + Assert.IsFalse(action.IsParallel); + } + + #endregion + + #region Timeout Tests for Conditional Operations + + [Test] + public void WaitWhile_TimeoutStopsWaiting() + { + bool completed = false; + float startTime = UnityEngine.Time.realtimeSinceStartup; + + // WaitWhile with a condition that's always true, but with timeout + JAction.Create() + .WaitWhile(() => true, frequency: 0, timeout: 0.1f) + .Do(() => completed = true) + .Execute(); + + float elapsed = UnityEngine.Time.realtimeSinceStartup - startTime; + + Assert.IsTrue(completed); + Assert.GreaterOrEqual(elapsed, 0.1f); // Should have waited for timeout + Assert.Less(elapsed, 0.5f); // But not too long + } + + [Test] + public void RepeatWhile_TimeoutStopsRepeating() + { + int repeatCount = 0; + float startTime = UnityEngine.Time.realtimeSinceStartup; + + // RepeatWhile with a condition that's always true, but with timeout + JAction.Create() + .RepeatWhile( + () => repeatCount++, + () => true, // Always true + frequency: 0, + timeout: 0.1f + ) + .Execute(); + + float elapsed = UnityEngine.Time.realtimeSinceStartup - startTime; + + Assert.Greater(repeatCount, 0); // Should have run at least once + Assert.GreaterOrEqual(elapsed, 0.1f); + Assert.Less(elapsed, 0.5f); + } + + [Test] + public void RepeatUntil_TimeoutStopsRepeating() + { + int repeatCount = 0; + float startTime = UnityEngine.Time.realtimeSinceStartup; + + // RepeatUntil with a condition that's never true, but with timeout + JAction.Create() + .RepeatUntil( + () => repeatCount++, + () => false, // Never true + frequency: 0, + timeout: 0.1f + ) + .Execute(); + + float elapsed = UnityEngine.Time.realtimeSinceStartup - startTime; + + Assert.Greater(repeatCount, 0); // Should have run at least once + Assert.GreaterOrEqual(elapsed, 0.1f); + Assert.Less(elapsed, 0.5f); + } + + [Test] + public void WaitUntil_TimeoutStopsWaiting() + { + bool completed = false; + float startTime = UnityEngine.Time.realtimeSinceStartup; + + // WaitUntil with a condition that's never true, but with timeout + JAction.Create() + .WaitUntil(() => false, frequency: 0, timeout: 0.1f) + .Do(() => completed = true) + .Execute(); + + float elapsed = UnityEngine.Time.realtimeSinceStartup - startTime; + + Assert.IsTrue(completed); + Assert.GreaterOrEqual(elapsed, 0.1f); + Assert.Less(elapsed, 0.5f); + } + #endregion #region Complex Chaining @@ -377,11 +767,11 @@ public void ComplexChain_ExecutesInOrder() var order = new List(); JAction.Create() - .Do(() => order.Add(1)) + .Do(static o => o.Add(1), order) .Delay(0.01f) - .Do(() => order.Add(2)) - .Repeat(() => order.Add(3), count: 2) - .Do(() => order.Add(4)) + .Do(static o => o.Add(2), order) + .Repeat(static o => o.Add(3), order, count: 2) + .Do(static o => o.Add(4), order) .Execute(); Assert.AreEqual(5, order.Count); @@ -525,4 +915,6 @@ private class TestData public int Value; } } + + #endregion } diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JEngine.Util.Editor.Tests.asmdef b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JEngine.Util.Editor.Tests.asmdef index c3ca9d27..b6bbc14d 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JEngine.Util.Editor.Tests.asmdef +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JEngine.Util.Editor.Tests.asmdef @@ -2,7 +2,8 @@ "name": "JEngine.Util.Editor.Tests", "rootNamespace": "JEngine.Util.Tests", "references": [ - "GUID:5c8e1f4d7a3b9e2c6f0d8a4b7e3c1f9d" + "JEngine.Util", + "UniTask" ], "includePlatforms": [ "Editor" @@ -19,4 +20,4 @@ ], "versionDefines": [], "noEngineReferences": false -} +} \ No newline at end of file diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs index f0d79a68..c20107f3 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs @@ -5,6 +5,7 @@ using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; +using Cysharp.Threading.Tasks; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; @@ -212,14 +213,14 @@ public IEnumerator ExecuteAsync_WithTimeout_CancelsPreemptively() return RunAsync(async () => { - using var action = await JAction.Create() + using var result = await JAction.Create() .Delay(2f) // 2 second delay .Do(() => completed = true) .OnCancel(() => cancelled = true) .ExecuteAsync(timeout: 0.1f); // 100ms timeout Assert.IsFalse(completed); - Assert.IsTrue(action.Cancelled); + Assert.IsTrue(result.Cancelled); Assert.IsTrue(cancelled); }); } @@ -325,13 +326,13 @@ public IEnumerator ComplexChain_Async_ExecutesInOrder() return RunAsync(async () => { using var action = await JAction.Create() - .Do(() => order.Add(1)) + .Do(static o => o.Add(1), order) .DelayFrame() - .Do(() => order.Add(2)) + .Do(static o => o.Add(2), order) .Delay(0.05f) - .Do(() => order.Add(3)) - .Repeat(() => order.Add(4), count: 2) - .Do(() => order.Add(5)) + .Do(static o => o.Add(3), order) + .Repeat(static o => o.Add(4), order, count: 2) + .Do(static o => o.Add(5), order) .ExecuteAsync(); Assert.AreEqual(6, order.Count); @@ -385,9 +386,9 @@ public IEnumerator UsingAwait_AutoDisposesAfterExecution() { // Use explicit using block so we can check pool count after disposal using (var action = await JAction.Create() - .Do(() => completed = true) - .DelayFrame() - .ExecuteAsync()) + .Do(() => completed = true) + .DelayFrame() + .ExecuteAsync()) { Assert.IsTrue(completed); Assert.IsFalse(action.Executing); @@ -400,9 +401,356 @@ public IEnumerator UsingAwait_AutoDisposesAfterExecution() #endregion + #region Async Function Tests + + [UnityTest] + public IEnumerator Do_AsyncFunc_ExecutesWithExecuteAsync() + { + bool asyncFuncCalled = false; + bool completed = false; + + return RunAsync(async () => + { + using var action = await JAction.Create() + .Do(() => + { + asyncFuncCalled = true; + // Return a default (already-completed) awaitable + return default; + }) + .Do(() => completed = true) + .ExecuteAsync(); + + Assert.IsTrue(asyncFuncCalled); + Assert.IsTrue(completed); + }); + } + + [UnityTest] + public IEnumerator Do_AsyncFunc_WaitsForCompletion() + { + var order = new List(); + + return RunAsync(async () => + { + // Create an inner action that we'll wait for + using var innerAction = JAction.Create() + .Do(static o => o.Add(1), order) + .DelayFrame(2) + .Do(static o => o.Add(2), order); + + using var action = await JAction.Create() + .Do(static o => o.Add(0), order) + .Do(static act => { _ = act.ExecuteAsync(); }, innerAction) // Fire and forget + .Do(static o => o.Add(3), order) + .ExecuteAsync(); + + // The inner action executes asynchronously, so order depends on timing + Assert.Contains(0, order); + Assert.Contains(3, order); + }); + } + + [UnityTest] + public IEnumerator Do_AsyncFuncWithState_PassesState() + { + var data = new TestData { Counter = 0 }; + bool completed = false; + + return RunAsync(async () => + { + using var action = await JAction.Create() + .Do(static d => + { + d.Counter = 42; + // Return a default (already-completed) awaitable + return default; + }, data) + .Do(() => completed = true) + .ExecuteAsync(); + + Assert.AreEqual(42, data.Counter); + Assert.IsTrue(completed); + }); + } + + #endregion + + #region Parallel Execution Tests + + [UnityTest] + public IEnumerator Parallel_AllowsConcurrentExecution() + { + // Note: This test is safe despite shared variables because Unity's PlayerLoop + // executes on a single thread. The increment/decrement operations are not + // racing with each other - they execute sequentially within the same frame. + int concurrentCount = 0; + int maxConcurrent = 0; + + return RunAsync(async () => + { + // Create a parallel action + using var action = JAction.Create() + .Parallel() + .Do(() => + { + concurrentCount++; + if (concurrentCount > maxConcurrent) maxConcurrent = concurrentCount; + }) + .DelayFrame(2) + .Do(() => concurrentCount--); + + // Start multiple executions concurrently + var task1 = action.ExecuteAsync(); + var task2 = action.ExecuteAsync(); + + await task1; + await task2; + + // With parallel mode, both should have run + // Note: maxConcurrent may be 1 or 2 depending on timing + Assert.GreaterOrEqual(maxConcurrent, 1); + }); + } + + [UnityTest] + public IEnumerator NonParallel_BlocksConcurrentExecution() + { + int executionCount = 0; + + return RunAsync(async () => + { + // Create a non-parallel action (default) + using var action = JAction.Create() + .Do(() => executionCount++) + .DelayFrame(2); + + // Start first execution + var task1 = action.ExecuteAsync(); + + // Verify action is executing + Assert.IsTrue(action.Executing); + + // Try to start second execution while first is running + // This should log a warning and return immediately + LogAssert.Expect(LogType.Warning, + "[JAction] Already executing. Enable Parallel() for concurrent execution."); + var task2 = action.ExecuteAsync(); + + // task2 should complete immediately (returns early) + await task2; + + // Wait for first execution to complete + await task1; + + // Only the first execution should have incremented + Assert.AreEqual(1, executionCount); + }); + } + + [UnityTest] + public IEnumerator Parallel_Property_ReflectsState() + { + return RunAsync(async () => + { + // Non-parallel by default + var action1 = JAction.Create(); + Assert.IsFalse(action1.IsParallel); + + // Enable parallel + var action2 = JAction.Create().Parallel(); + Assert.IsTrue(action2.IsParallel); + + action1.Dispose(); + action2.Dispose(); + + await UniTask.CompletedTask; + }); + } + + #endregion + + #region Timeout Tests (Runtime) + + [UnityTest] + public IEnumerator WaitWhile_WithTimeout_StopsAtTimeout() + { + bool completed = false; + float startTime = Time.realtimeSinceStartup; + + return RunAsync(async () => + { + using var action = await JAction.Create() + .WaitWhile(() => true, timeout: 0.15f) + .Do(() => completed = true) + .ExecuteAsync(); + + float elapsed = Time.realtimeSinceStartup - startTime; + + Assert.IsTrue(completed); + Assert.GreaterOrEqual(elapsed, 0.15f); + }); + } + + [UnityTest] + public IEnumerator RepeatWhile_WithTimeout_StopsAtTimeout() + { + int repeatCount = 0; + float startTime = Time.realtimeSinceStartup; + + return RunAsync(async () => + { + using var action = await JAction.Create() + .RepeatWhile( + () => repeatCount++, + () => true, + frequency: 0, + timeout: 0.15f + ) + .ExecuteAsync(); + + float elapsed = Time.realtimeSinceStartup - startTime; + + Assert.Greater(repeatCount, 0); + Assert.GreaterOrEqual(elapsed, 0.15f); + }); + } + + [UnityTest] + public IEnumerator RepeatUntil_WithTimeout_StopsAtTimeout() + { + int repeatCount = 0; + float startTime = Time.realtimeSinceStartup; + + return RunAsync(async () => + { + using var action = await JAction.Create() + .RepeatUntil( + () => repeatCount++, + () => false, + frequency: 0, + timeout: 0.15f + ) + .ExecuteAsync(); + + float elapsed = Time.realtimeSinceStartup - startTime; + + Assert.Greater(repeatCount, 0); + Assert.GreaterOrEqual(elapsed, 0.15f); + }); + } + + #endregion + + #region Edge Cases (Runtime) + + [UnityTest] + public IEnumerator ExecuteAsync_EmptyAction_CompletesImmediately() + { + return RunAsync(async () => + { + float startTime = Time.realtimeSinceStartup; + + using var result = await JAction.Create() + .ExecuteAsync(); + + float elapsed = Time.realtimeSinceStartup - startTime; + + Assert.IsFalse(result.Executing); + Assert.IsFalse(result.Cancelled); + Assert.Less(elapsed, 0.1f); // Should complete very quickly + }); + } + + [UnityTest] + public IEnumerator ExecuteAsync_WithZeroDelay_SkipsDelay() + { + bool completed = false; + + return RunAsync(async () => + { + float startTime = Time.realtimeSinceStartup; + + using var action = await JAction.Create() + .Delay(0f) + .Do(() => completed = true) + .ExecuteAsync(); + + float elapsed = Time.realtimeSinceStartup - startTime; + + Assert.IsTrue(completed); + Assert.Less(elapsed, 0.1f); + }); + } + + [UnityTest] + public IEnumerator Cancel_DuringAsyncExecution_StopsExecution() + { + bool step1 = false; + bool step2 = false; + bool cancelled = false; + + return RunAsync(async () => + { + using var action = JAction.Create() + .Do(() => step1 = true) + .Delay(1f) + .Do(() => step2 = true) + .OnCancel(() => cancelled = true); + + // Start execution (don't await yet) + var handle = action.ExecuteAsync(); + + // Wait a bit then cancel via handle (per-execution cancellation) + await UniTask.Delay(50); + handle.Cancel(); + + // Now await the handle to get the result + var result = await handle; + + Assert.IsTrue(step1); + Assert.IsFalse(step2); + Assert.IsTrue(cancelled); + Assert.IsTrue(result.Cancelled); + }); + } + + [UnityTest] + public IEnumerator Cancel_ParallelExecution_CancelsOnlySpecificHandle() + { + int cancelCount = 0; + + return RunAsync(async () => + { + using var action = JAction.Create() + .Parallel() + .Delay(1f) + .OnCancel(() => cancelCount++); + + // Start two parallel executions + var handle1 = action.ExecuteAsync(); + var handle2 = action.ExecuteAsync(); + + // Wait a bit then cancel only handle1 + await UniTask.Delay(50); + handle1.Cancel(); + + // Await both + var result1 = await handle1; + var result2 = await handle2; + + // Only handle1 should be cancelled + Assert.IsTrue(result1.Cancelled); + Assert.IsFalse(result2.Cancelled); + Assert.AreEqual(1, cancelCount); + }); + } + + #endregion + private class TestData { public int Counter; } } -} +} \ No newline at end of file diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JEngine.Util.Tests.asmdef b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JEngine.Util.Tests.asmdef index f27fe036..24263fef 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JEngine.Util.Tests.asmdef +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JEngine.Util.Tests.asmdef @@ -2,9 +2,10 @@ "name": "JEngine.Util.Tests", "rootNamespace": "JEngine.Util.Tests", "references": [ - "GUID:5c8e1f4d7a3b9e2c6f0d8a4b7e3c1f9d", + "JEngine.Util", "UnityEngine.TestRunner", - "UnityEditor.TestRunner" + "UnityEditor.TestRunner", + "UniTask" ], "includePlatforms": [], "excludePlatforms": [], @@ -19,4 +20,4 @@ ], "versionDefines": [], "noEngineReferences": false -} +} \ No newline at end of file