From a0eb18846530b35f57a3d9de6bce2923232222f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JasonXuDeveloper=20-=20=E5=82=91?= Date: Fri, 30 Jan 2026 17:21:39 +1100 Subject: [PATCH 1/6] feat(util): add parallel execution support to JAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add JActionExecutionContext for per-execution state isolation - Add JActionExecution result struct with per-execution Cancelled state - Add JActionExecutionHandle for per-execution cancellation control - Execute/ExecuteAsync now snapshot tasks enabling safe parallel execution - Switch from ValueTask to UniTask for better Unity integration - Add comprehensive tests for parallel execution, timeouts, and edge cases - Add MessageBox test hooks for pool state inspection - Update jaction skill documentation with new API and patterns Co-Authored-By: Claude Opus 4.5 Signed-off-by: JasonXuDeveloper - 傑 --- .claude-plugin/skills/jaction/SKILL.md | 334 ++++++----- .../Runtime/MessageBox.cs | 80 +++ .../Tests/Editor/MessageBoxTests.cs | 254 +++++++++ .../Internal/JActionExecutionContext.cs | 503 +++++++++++++++++ .../Internal/JActionExecutionContext.cs.meta | 11 + .../Runtime/Internal/JActionRunner.cs | 42 +- .../Runtime/JAction.cs | 529 +++++------------- .../Runtime/JActionAwaitable.cs | 29 +- .../Runtime/JActionExecution.cs | 71 +++ .../Runtime/JActionExecution.cs.meta | 11 + .../Runtime/JActionExecutionHandle.cs | 176 ++++++ .../Runtime/JActionExecutionHandle.cs.meta | 11 + .../Runtime/JEngine.Util.asmdef | 6 +- .../Tests/Editor/JActionTests.cs | 490 ++++++++++++++-- .../Editor/JEngine.Util.Editor.Tests.asmdef | 5 +- .../Tests/Runtime/JActionRuntimeTests.cs | 378 ++++++++++++- .../Tests/Runtime/JEngine.Util.Tests.asmdef | 7 +- 17 files changed, 2304 insertions(+), 633 deletions(-) create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionExecutionContext.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/Internal/JActionExecutionContext.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecution.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecution.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs.meta 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..af3175cc 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs @@ -124,6 +124,86 @@ 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 most recent message box (any will do for testing) + MessageBox target = null; + foreach (var box in ActiveMessageBoxes) + { + target = box; + break; + } + + if (target == null) return false; + + 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; + + MessageBox target = null; + foreach (var box in ActiveMessageBoxes) + { + target = box; + break; + } + + if (target == null || 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; + + MessageBox target = null; + foreach (var box in ActiveMessageBoxes) + { + target = box; + break; + } + + if (target == null) return null; + + 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..660be446 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) + public JActionExecutionHandle ExecuteAsync(float timeout = 0f) { - StartAsync(timeout); - await new JActionAwaitable(this); - return this; - } - - private void StartAsync(float timeout) - { - if (IsExecuting && !_parallel) + if (!_parallel && Executing) { Debug.LogWarning("[JAction] Already executing. Enable Parallel() for concurrent execution."); - return; + return new JActionExecutionHandle(this, null); } - 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; - } + if (_tasks.Count == 0) return new JActionExecutionHandle(this, null); - // Check timeout (preemptive cancellation) - if (_timeoutEndTime > 0f && Time.realtimeSinceStartup >= _timeoutEndTime) - { - Cancel(); - OnExecutionComplete(); - return true; - } + // Create execution context + var context = JActionExecutionContext.Rent(); + context.Initialize(_tasks, _onCancel, _onCancelDelegate, _onCancelState, timeout, blockingMode: false); - if (_currentTaskIndex >= _tasks.Count) - { - OnExecutionComplete(); - return true; - } + // Track context for Cancel() support + _activeContexts.Add(context); - if (ProcessCurrentTask()) - { - _currentTaskIndex++; - } + // Register with runner + JActionRunner.Register(context); - 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; + // 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) - { - _repeatCounter = 0; - _repeatLastTime = 0; - return true; - } - - if (task.FloatParam1 > 0 && _repeatCounter > 0) + if (context != null) { - if (currentTime - _repeatLastTime < task.FloatParam1) - return false; + _activeContexts.Remove(context); } - - _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; + // Cancel sync context if active + _syncContext?.Cancel(); - _cancelled = true; - - try + // Cancel all async contexts + for (int i = _activeContexts.Count - 1; i >= 0; i--) { - if (_onCancel != null) - { - _onCancel.Invoke(); - } - else if (_onCancelDelegate != null && _onCancelState != null) - { - _onCancelState.InvokeAction(_onCancelDelegate); - } - } - catch (Exception e) - { - 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,13 @@ 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; + _activeContexts.Clear(); + _syncContext = null; return this; } @@ -1202,7 +927,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..af359f25 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs @@ -0,0 +1,176 @@ +// 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() + { + await new JActionAwaitable(_context); + _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); + } + + /// + /// 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..c352d9fb 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 - // Force executing state - typeof(JAction).GetField("IsExecuting", - BindingFlags.NonPublic | - BindingFlags.Instance) - ?.SetValue(action, true); + Assert.AreEqual(42, result); + } + + [Test] + public void Cancel_NotExecuting_DoesNothing() + { + bool cancelled = false; + 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,223 @@ 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() + { + 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(() => { })); + + action.Dispose(); + } + + [Test] + public void Reset_ClearsTasks() + { + int counter = 0; + + 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); + action.Dispose(); + } + + [Test] + public void Reset_ClearsParallelMode() + { + var action = JAction.Create() + .Parallel(); + + Assert.IsTrue(action.IsParallel); + + action.Reset(); + + Assert.IsFalse(action.IsParallel); + action.Dispose(); + } + + #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 +771,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 +919,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..c76f875b 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,364 @@ 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 + 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); + + // Clean up inner action + innerAction.Dispose(); + }); + } + + [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() + { + int concurrentCount = 0; + int maxConcurrent = 0; + + return RunAsync(async () => + { + // Create a parallel action + 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); + + action.Dispose(); + }); + } + + [UnityTest] + public IEnumerator NonParallel_BlocksConcurrentExecution() + { + int executionCount = 0; + + return RunAsync(async () => + { + // Create a non-parallel action (default) + 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); + + action.Dispose(); + }); + } + + [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 Task.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 () => + { + 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); + + action.Dispose(); + }); + } + + [UnityTest] + public IEnumerator Cancel_ParallelExecution_CancelsOnlySpecificHandle() + { + int cancelCount = 0; + + return RunAsync(async () => + { + 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); + + action.Dispose(); + }); + } + + #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 From fcc2208e0b4fb94d2aab4bceca3def4b23eb4370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JasonXuDeveloper=20-=20=E5=82=91?= Date: Fri, 30 Jan 2026 17:29:54 +1100 Subject: [PATCH 2/6] fix(util): ensure proper disposal in tests with try-finally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeQL warnings about potential missed Dispose() calls by wrapping test code in try-finally blocks. Signed-off-by: JasonXuDeveloper - 傑 --- .../Tests/Editor/JActionTests.cs | 54 +++--- .../Tests/Runtime/JActionRuntimeTests.cs | 170 ++++++++++-------- 2 files changed, 129 insertions(+), 95 deletions(-) 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 c352d9fb..c57cab94 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JActionTests.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JActionTests.cs @@ -627,17 +627,21 @@ public void DelayFrame_NegativeValue_SkipsDelay() public void TaskCapacity_ThrowsWhenExceeded() { var action = JAction.Create(); + try + { + // Add 256 tasks (the max capacity) + for (int i = 0; i < 256; i++) + { + action.Do(() => { }); + } - // Add 256 tasks (the max capacity) - for (int i = 0; i < 256; i++) + // The 257th task should throw + Assert.Throws(() => action.Do(() => { })); + } + finally { - action.Do(() => { }); + action.Dispose(); } - - // The 257th task should throw - Assert.Throws(() => action.Do(() => { })); - - action.Dispose(); } [Test] @@ -648,15 +652,20 @@ public void Reset_ClearsTasks() var action = JAction.Create() .Do(() => counter++) .Do(() => counter++); + try + { + // Reset should clear the tasks + action.Reset(); - // Reset should clear the tasks - action.Reset(); - - // Execute after reset - should do nothing (no tasks) - action.Execute(); + // Execute after reset - should do nothing (no tasks) + action.Execute(); - Assert.AreEqual(0, counter); - action.Dispose(); + Assert.AreEqual(0, counter); + } + finally + { + action.Dispose(); + } } [Test] @@ -664,13 +673,18 @@ public void Reset_ClearsParallelMode() { var action = JAction.Create() .Parallel(); + try + { + Assert.IsTrue(action.IsParallel); - Assert.IsTrue(action.IsParallel); + action.Reset(); - action.Reset(); - - Assert.IsFalse(action.IsParallel); - action.Dispose(); + Assert.IsFalse(action.IsParallel); + } + finally + { + action.Dispose(); + } } #endregion 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 c76f875b..2b5ec6df 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs @@ -438,19 +438,23 @@ public IEnumerator Do_AsyncFunc_WaitsForCompletion() .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); - - // Clean up inner action - innerAction.Dispose(); + try + { + 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); + } + finally + { + // Clean up inner action + innerAction.Dispose(); + } }); } @@ -499,19 +503,23 @@ public IEnumerator Parallel_AllowsConcurrentExecution() }) .DelayFrame(2) .Do(() => concurrentCount--); + try + { + // Start multiple executions concurrently + var task1 = action.ExecuteAsync(); + var task2 = action.ExecuteAsync(); - // 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); + await task1; + await task2; - action.Dispose(); + // With parallel mode, both should have run + // Note: maxConcurrent may be 1 or 2 depending on timing + Assert.GreaterOrEqual(maxConcurrent, 1); + } + finally + { + action.Dispose(); + } }); } @@ -526,29 +534,33 @@ public IEnumerator NonParallel_BlocksConcurrentExecution() var action = JAction.Create() .Do(() => executionCount++) .DelayFrame(2); + try + { + // Start first execution + var task1 = action.ExecuteAsync(); - // 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(); + // Verify action is executing + Assert.IsTrue(action.Executing); - // task2 should complete immediately (returns early) - await task2; + // 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(); - // Wait for first execution to complete - await task1; + // task2 should complete immediately (returns early) + await task2; - // Only the first execution should have incremented - Assert.AreEqual(1, executionCount); + // Wait for first execution to complete + await task1; - action.Dispose(); + // Only the first execution should have incremented + Assert.AreEqual(1, executionCount); + } + finally + { + action.Dispose(); + } }); } @@ -701,23 +713,27 @@ public IEnumerator Cancel_DuringAsyncExecution_StopsExecution() .Delay(1f) .Do(() => step2 = true) .OnCancel(() => cancelled = true); + try + { + // Start execution (don't await yet) + var handle = action.ExecuteAsync(); - // 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; + // Wait a bit then cancel via handle (per-execution cancellation) + await UniTask.Delay(50); + handle.Cancel(); - Assert.IsTrue(step1); - Assert.IsFalse(step2); - Assert.IsTrue(cancelled); - Assert.IsTrue(result.Cancelled); + // Now await the handle to get the result + var result = await handle; - action.Dispose(); + Assert.IsTrue(step1); + Assert.IsFalse(step2); + Assert.IsTrue(cancelled); + Assert.IsTrue(result.Cancelled); + } + finally + { + action.Dispose(); + } }); } @@ -732,25 +748,29 @@ public IEnumerator Cancel_ParallelExecution_CancelsOnlySpecificHandle() .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); - - action.Dispose(); + try + { + // 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); + } + finally + { + action.Dispose(); + } }); } From e87746054e55b6e6c899c3453903de22d7c33845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JasonXuDeveloper=20-=20=E5=82=91?= Date: Fri, 30 Jan 2026 17:33:58 +1100 Subject: [PATCH 3/6] fix: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Task.CompletedTask -> UniTask.CompletedTask - Fix AsUniTask to delegate to awaiter (prevent double pool return) - Fix Reset to return execution contexts to pool before clearing - Fix MessageBox test hooks to use direct index access (avoid foreach) - Add comment explaining PlayerLoop single-threading in parallel test Signed-off-by: JasonXuDeveloper - 傑 --- .../Runtime/MessageBox.cs | 32 +++---------------- .../Runtime/JAction.cs | 14 +++++++- .../Runtime/JActionExecutionHandle.cs | 13 ++------ .../Tests/Runtime/JActionRuntimeTests.cs | 5 ++- 4 files changed, 25 insertions(+), 39 deletions(-) diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs index af3175cc..2bfd4615 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs @@ -143,16 +143,8 @@ internal static bool TestSimulateButtonClick(bool clickOk) { if (ActiveMessageBoxes.Count == 0) return false; - // Get the most recent message box (any will do for testing) - MessageBox target = null; - foreach (var box in ActiveMessageBoxes) - { - target = box; - break; - } - - if (target == null) return false; - + // Get the first message box (any will do for testing) + var target = ActiveMessageBoxes[0]; target.HandleEvent(clickOk); return true; } @@ -165,14 +157,8 @@ internal static (bool okVisible, bool noVisible)? TestGetButtonVisibility() { if (ActiveMessageBoxes.Count == 0) return null; - MessageBox target = null; - foreach (var box in ActiveMessageBoxes) - { - target = box; - break; - } - - if (target == null || target._buttonOk == null || target._buttonNo == null) + var target = ActiveMessageBoxes[0]; + if (target._buttonOk == null || target._buttonNo == null) return null; return (target._buttonOk.gameObject.activeSelf, target._buttonNo.gameObject.activeSelf); @@ -186,15 +172,7 @@ internal static (string title, string content, string okText, string noText)? Te { if (ActiveMessageBoxes.Count == 0) return null; - MessageBox target = null; - foreach (var box in ActiveMessageBoxes) - { - target = box; - break; - } - - if (target == null) return null; - + var target = ActiveMessageBoxes[0]; return ( target._title?.text, target._content?.text, diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JAction.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JAction.cs index 660be446..bd87933a 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JAction.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JAction.cs @@ -910,8 +910,20 @@ public JAction Reset(bool force = false) _onCancelDelegate = null; _onCancelState?.Return(); _onCancelState = null; + + // Return active execution contexts to pool before clearing + for (int i = 0; i < _activeContexts.Count; i++) + { + JActionExecutionContext.Return(_activeContexts[i]); + } _activeContexts.Clear(); - _syncContext = null; + + // Return sync execution context to pool + if (_syncContext != null) + { + JActionExecutionContext.Return(_syncContext); + _syncContext = null; + } return this; } diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs index af359f25..d06ba7bd 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Runtime/JActionExecutionHandle.cs @@ -87,16 +87,9 @@ public void Cancel() /// public async UniTask AsUniTask() { - await new JActionAwaitable(_context); - _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); + // Delegate to the handle's awaiter so that completion and cleanup + // are managed in a single, centralized location. + return await this; } /// 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 2b5ec6df..c07d57ac 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs @@ -488,6 +488,9 @@ public IEnumerator Do_AsyncFuncWithState_PassesState() [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; @@ -580,7 +583,7 @@ public IEnumerator Parallel_Property_ReflectsState() action1.Dispose(); action2.Dispose(); - await Task.CompletedTask; + await UniTask.CompletedTask; }); } From 9b5e4ff7db54d552e5a27a8f9afa137b5fa9e9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JasonXuDeveloper=20-=20=E5=82=91?= Date: Fri, 30 Jan 2026 17:38:22 +1100 Subject: [PATCH 4/6] refactor(util): use 'using' statements instead of try-finally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert try-finally patterns to 'using' declarations for cleaner resource management as suggested by CodeQL static analysis. Co-Authored-By: Claude Opus 4.5 Signed-off-by: JasonXuDeveloper - 傑 --- .../Tests/Editor/JActionTests.cs | 56 ++---- .../Tests/Runtime/JActionRuntimeTests.cs | 169 +++++++----------- 2 files changed, 88 insertions(+), 137 deletions(-) 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 c57cab94..077afb47 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JActionTests.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Editor/JActionTests.cs @@ -626,22 +626,16 @@ public void DelayFrame_NegativeValue_SkipsDelay() [Test] public void TaskCapacity_ThrowsWhenExceeded() { - var action = JAction.Create(); - try - { - // Add 256 tasks (the max capacity) - for (int i = 0; i < 256; i++) - { - action.Do(() => { }); - } + using var action = JAction.Create(); - // The 257th task should throw - Assert.Throws(() => action.Do(() => { })); - } - finally + // Add 256 tasks (the max capacity) + for (int i = 0; i < 256; i++) { - action.Dispose(); + action.Do(() => { }); } + + // The 257th task should throw + Assert.Throws(() => action.Do(() => { })); } [Test] @@ -649,42 +643,30 @@ public void Reset_ClearsTasks() { int counter = 0; - var action = JAction.Create() + using var action = JAction.Create() .Do(() => counter++) .Do(() => counter++); - try - { - // Reset should clear the tasks - action.Reset(); - // Execute after reset - should do nothing (no tasks) - action.Execute(); + // Reset should clear the tasks + action.Reset(); - Assert.AreEqual(0, counter); - } - finally - { - action.Dispose(); - } + // Execute after reset - should do nothing (no tasks) + action.Execute(); + + Assert.AreEqual(0, counter); } [Test] public void Reset_ClearsParallelMode() { - var action = JAction.Create() + using var action = JAction.Create() .Parallel(); - try - { - Assert.IsTrue(action.IsParallel); - action.Reset(); + Assert.IsTrue(action.IsParallel); - Assert.IsFalse(action.IsParallel); - } - finally - { - action.Dispose(); - } + action.Reset(); + + Assert.IsFalse(action.IsParallel); } #endregion 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 c07d57ac..c20107f3 100644 --- a/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.util/Tests/Runtime/JActionRuntimeTests.cs @@ -434,27 +434,20 @@ public IEnumerator Do_AsyncFunc_WaitsForCompletion() return RunAsync(async () => { // Create an inner action that we'll wait for - var innerAction = JAction.Create() + using var innerAction = JAction.Create() .Do(static o => o.Add(1), order) .DelayFrame(2) .Do(static o => o.Add(2), order); - try - { - 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); - } - finally - { - // Clean up inner action - innerAction.Dispose(); - } + + 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); }); } @@ -497,7 +490,7 @@ public IEnumerator Parallel_AllowsConcurrentExecution() return RunAsync(async () => { // Create a parallel action - var action = JAction.Create() + using var action = JAction.Create() .Parallel() .Do(() => { @@ -506,23 +499,17 @@ public IEnumerator Parallel_AllowsConcurrentExecution() }) .DelayFrame(2) .Do(() => concurrentCount--); - try - { - // Start multiple executions concurrently - var task1 = action.ExecuteAsync(); - var task2 = action.ExecuteAsync(); - await task1; - await task2; + // Start multiple executions concurrently + var task1 = action.ExecuteAsync(); + var task2 = action.ExecuteAsync(); - // With parallel mode, both should have run - // Note: maxConcurrent may be 1 or 2 depending on timing - Assert.GreaterOrEqual(maxConcurrent, 1); - } - finally - { - action.Dispose(); - } + 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); }); } @@ -534,36 +521,30 @@ public IEnumerator NonParallel_BlocksConcurrentExecution() return RunAsync(async () => { // Create a non-parallel action (default) - var action = JAction.Create() + using var action = JAction.Create() .Do(() => executionCount++) .DelayFrame(2); - try - { - // Start first execution - var task1 = action.ExecuteAsync(); - // Verify action is executing - Assert.IsTrue(action.Executing); + // Start first execution + var task1 = action.ExecuteAsync(); - // 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(); + // Verify action is executing + Assert.IsTrue(action.Executing); - // task2 should complete immediately (returns early) - await task2; + // 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(); - // Wait for first execution to complete - await task1; + // task2 should complete immediately (returns early) + await task2; - // Only the first execution should have incremented - Assert.AreEqual(1, executionCount); - } - finally - { - action.Dispose(); - } + // Wait for first execution to complete + await task1; + + // Only the first execution should have incremented + Assert.AreEqual(1, executionCount); }); } @@ -711,32 +692,26 @@ public IEnumerator Cancel_DuringAsyncExecution_StopsExecution() return RunAsync(async () => { - var action = JAction.Create() + using var action = JAction.Create() .Do(() => step1 = true) .Delay(1f) .Do(() => step2 = true) .OnCancel(() => cancelled = true); - try - { - // 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(); + // Start execution (don't await yet) + var handle = action.ExecuteAsync(); - // Now await the handle to get the result - var result = await handle; + // Wait a bit then cancel via handle (per-execution cancellation) + await UniTask.Delay(50); + handle.Cancel(); - Assert.IsTrue(step1); - Assert.IsFalse(step2); - Assert.IsTrue(cancelled); - Assert.IsTrue(result.Cancelled); - } - finally - { - action.Dispose(); - } + // 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); }); } @@ -747,33 +722,27 @@ public IEnumerator Cancel_ParallelExecution_CancelsOnlySpecificHandle() return RunAsync(async () => { - var action = JAction.Create() + using var action = JAction.Create() .Parallel() .Delay(1f) .OnCancel(() => cancelCount++); - try - { - // 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); - } - finally - { - action.Dispose(); - } + + // 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); }); } From 1ed756686909c9f5174c482bca9bab91254f7d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JasonXuDeveloper=20-=20=E5=82=91?= Date: Fri, 30 Jan 2026 17:43:16 +1100 Subject: [PATCH 5/6] fix(ui): use LINQ First() instead of array indexing on HashSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HashSet does not support array indexing. Use LINQ First() method to get the first element from the collection. Co-Authored-By: Claude Opus 4.5 Signed-off-by: JasonXuDeveloper - 傑 --- .../com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Runtime/MessageBox.cs index 2bfd4615..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; @@ -144,7 +145,7 @@ 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[0]; + var target = ActiveMessageBoxes.First(); target.HandleEvent(clickOk); return true; } @@ -157,7 +158,7 @@ internal static (bool okVisible, bool noVisible)? TestGetButtonVisibility() { if (ActiveMessageBoxes.Count == 0) return null; - var target = ActiveMessageBoxes[0]; + var target = ActiveMessageBoxes.First(); if (target._buttonOk == null || target._buttonNo == null) return null; @@ -172,7 +173,7 @@ internal static (string title, string content, string okText, string noText)? Te { if (ActiveMessageBoxes.Count == 0) return null; - var target = ActiveMessageBoxes[0]; + var target = ActiveMessageBoxes.First(); return ( target._title?.text, target._content?.text, From 362ad662731929b69d7895580c985f3e7127de8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JasonXuDeveloper=20-=20=E5=82=91?= Date: Fri, 30 Jan 2026 18:58:52 +1100 Subject: [PATCH 6/6] test(ui): add comprehensive tests for UI Editor components Add 22 new test files covering all UI Editor components: - Button: JButton, JButtonGroup, JIconButton, JToggleButton - Form: JDropdown, JFormField, JObjectField, JTextField, JToggle - Layout: JCard, JRow, JSection, JStack - Feedback: JLogView, JProgressBar, JStatusBar - Navigation: JBreadcrumb - Theming: JTheme, Tokens - Utilities: EnumHelpers, StyleSheetManager - Base: JComponent Coverage improved from 14% to 61.7% for UI.Editor package. Uses reflection for button click testing with graceful fallback. Co-Authored-By: Claude Opus 4.5 --- .../Tests/Editor/Components.meta | 8 + .../Tests/Editor/Components/Base.meta | 8 + .../Editor/Components/Base/JComponentTests.cs | 284 ++++++++ .../Components/Base/JComponentTests.cs.meta | 11 + .../Tests/Editor/Components/Button.meta | 8 + .../Components/Button/JButtonGroupTests.cs | 327 ++++++++++ .../Button/JButtonGroupTests.cs.meta | 11 + .../Editor/Components/Button/JButtonTests.cs | 413 ++++++++++++ .../Components/Button/JButtonTests.cs.meta | 11 + .../Components/Button/JIconButtonTests.cs | 263 ++++++++ .../Button/JIconButtonTests.cs.meta | 11 + .../Components/Button/JToggleButtonTests.cs | 405 ++++++++++++ .../Button/JToggleButtonTests.cs.meta | 11 + .../Tests/Editor/Components/Feedback.meta | 8 + .../Components/Feedback/JLogViewTests.cs | 386 +++++++++++ .../Components/Feedback/JLogViewTests.cs.meta | 11 + .../Components/Feedback/JProgressBarTests.cs | 315 +++++++++ .../Feedback/JProgressBarTests.cs.meta | 11 + .../Components/Feedback/JStatusBarTests.cs | 293 +++++++++ .../Feedback/JStatusBarTests.cs.meta | 11 + .../Tests/Editor/Components/Form.meta | 8 + .../Editor/Components/Form/JDropdownTests.cs | 323 +++++++++ .../Components/Form/JDropdownTests.cs.meta | 11 + .../Editor/Components/Form/JFormFieldTests.cs | 330 ++++++++++ .../Components/Form/JFormFieldTests.cs.meta | 11 + .../Components/Form/JObjectFieldTests.cs | 225 +++++++ .../Components/Form/JObjectFieldTests.cs.meta | 11 + .../Editor/Components/Form/JTextFieldTests.cs | 267 ++++++++ .../Components/Form/JTextFieldTests.cs.meta | 11 + .../Editor/Components/Form/JToggleTests.cs | 316 +++++++++ .../Components/Form/JToggleTests.cs.meta | 11 + .../Tests/Editor/Components/Layout.meta | 8 + .../Editor/Components/Layout/JCardTests.cs | 203 ++++++ .../Components/Layout/JCardTests.cs.meta | 11 + .../Editor/Components/Layout/JRowTests.cs | 281 ++++++++ .../Components/Layout/JRowTests.cs.meta | 11 + .../Editor/Components/Layout/JSectionTests.cs | 319 +++++++++ .../Components/Layout/JSectionTests.cs.meta | 11 + .../Editor/Components/Layout/JStackTests.cs | 252 ++++++++ .../Components/Layout/JStackTests.cs.meta | 11 + .../Tests/Editor/Components/Navigation.meta | 8 + .../Components/Navigation/JBreadcrumbTests.cs | 360 +++++++++++ .../Navigation/JBreadcrumbTests.cs.meta | 11 + .../Editor/JEngine.UI.Editor.Tests.asmdef | 1 + .../Tests/Editor/Theming.meta | 8 + .../Tests/Editor/Theming/JThemeTests.cs | 611 ++++++++++++++++++ .../Tests/Editor/Theming/JThemeTests.cs.meta | 11 + .../Tests/Editor/Theming/TokensTests.cs | 544 ++++++++++++++++ .../Tests/Editor/Theming/TokensTests.cs.meta | 11 + .../Tests/Editor/Utilities.meta | 8 + .../Editor/Utilities/EnumHelpersTests.cs | 240 +++++++ .../Editor/Utilities/EnumHelpersTests.cs.meta | 11 + .../Utilities/StyleSheetManagerTests.cs | 232 +++++++ .../Utilities/StyleSheetManagerTests.cs.meta | 11 + .../Settings.json | 38 +- 55 files changed, 7541 insertions(+), 1 deletion(-) create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base/JComponentTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base/JComponentTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonGroupTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonGroupTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JIconButtonTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JIconButtonTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JToggleButtonTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JToggleButtonTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JLogViewTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JLogViewTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JProgressBarTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JProgressBarTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JStatusBarTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JStatusBarTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JDropdownTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JDropdownTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JFormFieldTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JFormFieldTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JObjectFieldTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JObjectFieldTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JTextFieldTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JTextFieldTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JToggleTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Form/JToggleTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout/JCardTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout/JCardTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout/JRowTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout/JRowTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout/JSectionTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout/JSectionTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout/JStackTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Layout/JStackTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Navigation.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Navigation/JBreadcrumbTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Navigation/JBreadcrumbTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Theming.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Theming/JThemeTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Theming/JThemeTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Theming/TokensTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Theming/TokensTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Utilities.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Utilities/EnumHelpersTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Utilities/EnumHelpersTests.cs.meta create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Utilities/StyleSheetManagerTests.cs create mode 100644 UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Utilities/StyleSheetManagerTests.cs.meta diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components.meta new file mode 100644 index 00000000..556025bf --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7231d249d43214879a1917bf44c84bfc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base.meta new file mode 100644 index 00000000..219f604a --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 59b3bf8b64fb24fe2bdbf3de43b25183 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base/JComponentTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base/JComponentTests.cs new file mode 100644 index 00000000..9cf76529 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base/JComponentTests.cs @@ -0,0 +1,284 @@ +// JComponentTests.cs +// EditMode unit tests for JComponent base class + +using NUnit.Framework; +using UnityEngine.UIElements; +using JEngine.UI.Editor.Components; +using JEngine.UI.Editor.Components.Layout; + +namespace JEngine.UI.Tests.Editor.Components.Base +{ + [TestFixture] + public class JComponentTests + { + // Using JStack as concrete implementation of JComponent + private JStack _component; + + [SetUp] + public void SetUp() + { + _component = new JStack(); + } + + #region Constructor Tests + + [Test] + public void Constructor_WithBaseClassName_AddsClass() + { + // JStack inherits from JComponent with "j-stack" as base class + Assert.IsTrue(_component.ClassListContains("j-stack")); + } + + #endregion + + #region WithClass Tests + + [Test] + public void WithClass_AddsClassName() + { + _component.WithClass("custom-class"); + Assert.IsTrue(_component.ClassListContains("custom-class")); + } + + [Test] + public void WithClass_ReturnsComponentForChaining() + { + var result = _component.WithClass("test"); + Assert.AreSame(_component, result); + } + + [Test] + public void WithClass_CanAddMultipleClasses() + { + _component.WithClass("class1"); + _component.WithClass("class2"); + + Assert.IsTrue(_component.ClassListContains("class1")); + Assert.IsTrue(_component.ClassListContains("class2")); + } + + #endregion + + #region WithName Tests + + [Test] + public void WithName_SetsElementName() + { + _component.WithName("test-element"); + Assert.AreEqual("test-element", _component.name); + } + + [Test] + public void WithName_ReturnsComponentForChaining() + { + var result = _component.WithName("test"); + Assert.AreSame(_component, result); + } + + [Test] + public void WithName_CanOverwritePreviousName() + { + _component.WithName("first"); + _component.WithName("second"); + Assert.AreEqual("second", _component.name); + } + + #endregion + + #region Add Tests + + [Test] + public void Add_SingleChild_AddsToComponent() + { + var child = new Label("test"); + _component.Add(child); + + Assert.AreEqual(1, _component.childCount); + Assert.AreSame(child, _component[0]); + } + + [Test] + public void Add_MultipleChildren_AddsAllToComponent() + { + var child1 = new Label("test1"); + var child2 = new Label("test2"); + var child3 = new Label("test3"); + + _component.Add(child1, child2, child3); + + Assert.AreEqual(3, _component.childCount); + } + + [Test] + public void Add_NullChild_IsIgnored() + { + _component.Add((VisualElement)null); + Assert.AreEqual(0, _component.childCount); + } + + [Test] + public void Add_MixedNullAndValid_AddsOnlyValidChildren() + { + var child1 = new Label("test1"); + var child2 = new Label("test2"); + + _component.Add(child1, null, child2); + + Assert.AreEqual(2, _component.childCount); + } + + [Test] + public void Add_ReturnsComponentForChaining() + { + var result = _component.Add(new Label()); + Assert.AreSame(_component, result); + } + + #endregion + + #region WithFlexGrow Tests + + [Test] + public void WithFlexGrow_SetsFlexGrowValue() + { + _component.WithFlexGrow(2f); + Assert.AreEqual(2f, _component.style.flexGrow.value); + } + + [Test] + public void WithFlexGrow_ReturnsComponentForChaining() + { + var result = _component.WithFlexGrow(1f); + Assert.AreSame(_component, result); + } + + [Test] + public void WithFlexGrow_ZeroValue_SetsToZero() + { + _component.WithFlexGrow(0f); + Assert.AreEqual(0f, _component.style.flexGrow.value); + } + + #endregion + + #region WithFlexShrink Tests + + [Test] + public void WithFlexShrink_SetsFlexShrinkValue() + { + _component.WithFlexShrink(2f); + Assert.AreEqual(2f, _component.style.flexShrink.value); + } + + [Test] + public void WithFlexShrink_ReturnsComponentForChaining() + { + var result = _component.WithFlexShrink(1f); + Assert.AreSame(_component, result); + } + + #endregion + + #region WithMargin Tests + + [Test] + public void WithMargin_SetsAllMargins() + { + _component.WithMargin(10f); + + Assert.AreEqual(10f, _component.style.marginTop.value.value); + Assert.AreEqual(10f, _component.style.marginRight.value.value); + Assert.AreEqual(10f, _component.style.marginBottom.value.value); + Assert.AreEqual(10f, _component.style.marginLeft.value.value); + } + + [Test] + public void WithMargin_ReturnsComponentForChaining() + { + var result = _component.WithMargin(5f); + Assert.AreSame(_component, result); + } + + #endregion + + #region WithPadding Tests + + [Test] + public void WithPadding_SetsAllPadding() + { + _component.WithPadding(10f); + + Assert.AreEqual(10f, _component.style.paddingTop.value.value); + Assert.AreEqual(10f, _component.style.paddingRight.value.value); + Assert.AreEqual(10f, _component.style.paddingBottom.value.value); + Assert.AreEqual(10f, _component.style.paddingLeft.value.value); + } + + [Test] + public void WithPadding_ReturnsComponentForChaining() + { + var result = _component.WithPadding(5f); + Assert.AreSame(_component, result); + } + + #endregion + + #region WithVisibility Tests + + [Test] + public void WithVisibility_True_SetsDisplayFlex() + { + _component.WithVisibility(true); + Assert.AreEqual(DisplayStyle.Flex, _component.style.display.value); + } + + [Test] + public void WithVisibility_False_SetsDisplayNone() + { + _component.WithVisibility(false); + Assert.AreEqual(DisplayStyle.None, _component.style.display.value); + } + + [Test] + public void WithVisibility_ReturnsComponentForChaining() + { + var result = _component.WithVisibility(true); + Assert.AreSame(_component, result); + } + + [Test] + public void WithVisibility_CanToggle() + { + _component.WithVisibility(false); + Assert.AreEqual(DisplayStyle.None, _component.style.display.value); + + _component.WithVisibility(true); + Assert.AreEqual(DisplayStyle.Flex, _component.style.display.value); + } + + #endregion + + #region Chaining Tests + + [Test] + public void FluentApi_CanChainMultipleMethods() + { + _component + .WithName("test") + .WithClass("custom") + .WithMargin(5f) + .WithPadding(10f) + .WithFlexGrow(1f) + .WithVisibility(true); + + Assert.AreEqual("test", _component.name); + Assert.IsTrue(_component.ClassListContains("custom")); + Assert.AreEqual(5f, _component.style.marginTop.value.value); + Assert.AreEqual(10f, _component.style.paddingTop.value.value); + Assert.AreEqual(1f, _component.style.flexGrow.value); + Assert.AreEqual(DisplayStyle.Flex, _component.style.display.value); + } + + #endregion + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base/JComponentTests.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base/JComponentTests.cs.meta new file mode 100644 index 00000000..ff4c277e --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Base/JComponentTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83961c4f36a57416ea84071c6a6a3dc9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button.meta new file mode 100644 index 00000000..aff19ee7 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c0828e7437a554f69b5cd26d83873027 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonGroupTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonGroupTests.cs new file mode 100644 index 00000000..b0bef798 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonGroupTests.cs @@ -0,0 +1,327 @@ +// JButtonGroupTests.cs +// EditMode unit tests for JButtonGroup + +using NUnit.Framework; +using UnityEngine.UIElements; +using JEngine.UI.Editor.Components.Button; +using JEngine.UI.Editor.Theming; + +namespace JEngine.UI.Tests.Editor.Components.Button +{ + [TestFixture] + public class JButtonGroupTests + { + private JButtonGroup _buttonGroup; + + [SetUp] + public void SetUp() + { + _buttonGroup = new JButtonGroup(); + } + + #region Constructor Tests + + [Test] + public void Constructor_Empty_AddsBaseClass() + { + Assert.IsTrue(_buttonGroup.ClassListContains("j-button-group")); + } + + [Test] + public void Constructor_Empty_HasNoChildren() + { + Assert.AreEqual(0, _buttonGroup.childCount); + } + + [Test] + public void Constructor_SetsRowDirection() + { + Assert.AreEqual(FlexDirection.Row, _buttonGroup.style.flexDirection.value); + } + + [Test] + public void Constructor_SetsFlexWrap() + { + Assert.AreEqual(Wrap.Wrap, _buttonGroup.style.flexWrap.value); + } + + [Test] + public void Constructor_SetsCenterAlignment() + { + Assert.AreEqual(Align.Center, _buttonGroup.style.alignItems.value); + } + + [Test] + public void Constructor_WithButtons_AddsAllButtons() + { + var btn1 = new JButton("Button 1"); + var btn2 = new JButton("Button 2"); + var group = new JButtonGroup(btn1, btn2); + + Assert.AreEqual(2, group.childCount); + } + + [Test] + public void Constructor_WithButtons_SetsRightMargin() + { + var btn1 = new JButton("Button 1"); + var btn2 = new JButton("Button 2"); + var group = new JButtonGroup(btn1, btn2); + + Assert.AreEqual(Tokens.Spacing.Sm, group[0].style.marginRight.value.value); + } + + [Test] + public void Constructor_WithButtons_LastButtonHasNoRightMargin() + { + var btn1 = new JButton("Button 1"); + var btn2 = new JButton("Button 2"); + var group = new JButtonGroup(btn1, btn2); + + Assert.AreEqual(0f, group[1].style.marginRight.value.value); + } + + [Test] + public void Constructor_WithButtons_SetsBottomMargin() + { + var btn1 = new JButton("Button 1"); + var group = new JButtonGroup(btn1); + + Assert.AreEqual(Tokens.Spacing.Xs, group[0].style.marginBottom.value.value); + } + + [Test] + public void Constructor_WithButtons_SetsFlexGrow() + { + var btn1 = new JButton("Button 1"); + var group = new JButtonGroup(btn1); + + Assert.AreEqual(1f, group[0].style.flexGrow.value); + } + + [Test] + public void Constructor_WithButtons_SetsFlexShrinkZero() + { + var btn1 = new JButton("Button 1"); + var group = new JButtonGroup(btn1); + + Assert.AreEqual(0f, group[0].style.flexShrink.value); + } + + [Test] + public void Constructor_WithButtons_SetsMinWidth() + { + var btn1 = new JButton("Button 1"); + var group = new JButtonGroup(btn1); + + Assert.AreEqual(100f, group[0].style.minWidth.value.value); + } + + [Test] + public void Constructor_WithNullButton_IgnoresNull() + { + var btn1 = new JButton("Button 1"); + var group = new JButtonGroup(btn1, null); + + Assert.AreEqual(1, group.childCount); + } + + #endregion + + #region Add Tests + + [Test] + public void Add_SingleButton_AddsToGroup() + { + var btn = new JButton("Test"); + _buttonGroup.Add(btn); + + Assert.AreEqual(1, _buttonGroup.childCount); + } + + [Test] + public void Add_MultipleButtons_AddsAllToGroup() + { + var btn1 = new JButton("Button 1"); + var btn2 = new JButton("Button 2"); + + _buttonGroup.Add(btn1, btn2); + + Assert.AreEqual(2, _buttonGroup.childCount); + } + + [Test] + public void Add_ReturnsGroupForChaining() + { + var result = _buttonGroup.Add(new JButton("Test")); + Assert.AreSame(_buttonGroup, result); + } + + [Test] + public void Add_NullButton_IsIgnored() + { + _buttonGroup.Add((VisualElement)null); + Assert.AreEqual(0, _buttonGroup.childCount); + } + + [Test] + public void Add_SetsMargins() + { + var btn1 = new JButton("Button 1"); + var btn2 = new JButton("Button 2"); + + _buttonGroup.Add(btn1, btn2); + + Assert.AreEqual(Tokens.Spacing.Sm, _buttonGroup[0].style.marginRight.value.value); + Assert.AreEqual(0f, _buttonGroup[1].style.marginRight.value.value); + } + + [Test] + public void Add_UpdatesMargins_WhenAddingMore() + { + var btn1 = new JButton("Button 1"); + _buttonGroup.Add(btn1); + Assert.AreEqual(0f, _buttonGroup[0].style.marginRight.value.value); + + var btn2 = new JButton("Button 2"); + _buttonGroup.Add(btn2); + + Assert.AreEqual(Tokens.Spacing.Sm, _buttonGroup[0].style.marginRight.value.value); + Assert.AreEqual(0f, _buttonGroup[1].style.marginRight.value.value); + } + + [Test] + public void Add_CanChainMultipleAddCalls() + { + _buttonGroup + .Add(new JButton("1")) + .Add(new JButton("2")) + .Add(new JButton("3")); + + Assert.AreEqual(3, _buttonGroup.childCount); + } + + #endregion + + #region NoWrap Tests + + [Test] + public void NoWrap_SetsNoWrap() + { + _buttonGroup.NoWrap(); + Assert.AreEqual(Wrap.NoWrap, _buttonGroup.style.flexWrap.value); + } + + [Test] + public void NoWrap_ReturnsGroupForChaining() + { + var result = _buttonGroup.NoWrap(); + Assert.AreSame(_buttonGroup, result); + } + + #endregion + + #region FixedWidth Tests + + [Test] + public void FixedWidth_SetsFlexGrowToZero() + { + var btn = new JButton("Test"); + _buttonGroup.Add(btn); + + _buttonGroup.FixedWidth(); + + Assert.AreEqual(0f, _buttonGroup[0].style.flexGrow.value); + } + + [Test] + public void FixedWidth_SetsFlexBasisAuto() + { + var btn = new JButton("Test"); + _buttonGroup.Add(btn); + + _buttonGroup.FixedWidth(); + + Assert.AreEqual(StyleKeyword.Auto, _buttonGroup[0].style.flexBasis.keyword); + } + + [Test] + public void FixedWidth_AffectsAllChildren() + { + var btn1 = new JButton("Button 1"); + var btn2 = new JButton("Button 2"); + _buttonGroup.Add(btn1, btn2); + + _buttonGroup.FixedWidth(); + + Assert.AreEqual(0f, _buttonGroup[0].style.flexGrow.value); + Assert.AreEqual(0f, _buttonGroup[1].style.flexGrow.value); + } + + [Test] + public void FixedWidth_ReturnsGroupForChaining() + { + var result = _buttonGroup.FixedWidth(); + Assert.AreSame(_buttonGroup, result); + } + + #endregion + + #region Inherited JComponent Tests + + [Test] + public void WithClass_AddsClassName() + { + _buttonGroup.WithClass("custom-class"); + Assert.IsTrue(_buttonGroup.ClassListContains("custom-class")); + } + + [Test] + public void WithClass_PreservesBaseClass() + { + _buttonGroup.WithClass("custom"); + Assert.IsTrue(_buttonGroup.ClassListContains("j-button-group")); + } + + [Test] + public void WithName_SetsElementName() + { + _buttonGroup.WithName("test-group"); + Assert.AreEqual("test-group", _buttonGroup.name); + } + + [Test] + public void WithVisibility_False_HidesGroup() + { + _buttonGroup.WithVisibility(false); + Assert.AreEqual(DisplayStyle.None, _buttonGroup.style.display.value); + } + + #endregion + + #region Chaining Tests + + [Test] + public void FluentApi_CanChainMultipleMethods() + { + // JButtonGroup-specific methods chain together + _buttonGroup + .Add(new JButton("1")) + .Add(new JButton("2")) + .NoWrap() + .FixedWidth(); + + // JComponent methods called separately (they return JComponent) + _buttonGroup.WithName("my-group"); + _buttonGroup.WithClass("custom"); + + Assert.AreEqual("my-group", _buttonGroup.name); + Assert.IsTrue(_buttonGroup.ClassListContains("custom")); + Assert.AreEqual(2, _buttonGroup.childCount); + Assert.AreEqual(Wrap.NoWrap, _buttonGroup.style.flexWrap.value); + Assert.AreEqual(0f, _buttonGroup[0].style.flexGrow.value); + } + + #endregion + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonGroupTests.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonGroupTests.cs.meta new file mode 100644 index 00000000..eb97dcee --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonGroupTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b0acbac7eb294354a0a0afafa3c936c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonTests.cs new file mode 100644 index 00000000..c0ddcd04 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonTests.cs @@ -0,0 +1,413 @@ +// JButtonTests.cs +// EditMode unit tests for JButton + +using System; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.UIElements; +using JEngine.UI.Editor.Components.Button; +using JEngine.UI.Editor.Theming; + +namespace JEngine.UI.Tests.Editor.Components.Button +{ + [TestFixture] + public class JButtonTests + { + private JButton _button; + + [SetUp] + public void SetUp() + { + _button = new JButton("Test"); + } + + #region Constructor Tests + + [Test] + public void Constructor_SetsTextProperty() + { + var button = new JButton("Hello"); + Assert.AreEqual("Hello", button.text); + } + + [Test] + public void Constructor_AddsBaseClass() + { + Assert.IsTrue(_button.ClassListContains("j-button")); + } + + [Test] + public void Constructor_DefaultVariant_IsPrimary() + { + Assert.AreEqual(ButtonVariant.Primary, _button.Variant); + } + + [Test] + public void Constructor_WithVariant_SetsVariant() + { + var button = new JButton("Test", variant: ButtonVariant.Secondary); + Assert.AreEqual(ButtonVariant.Secondary, button.Variant); + } + + [Test] + public void Constructor_WithClickHandler_RegistersCallback() + { + bool clicked = false; + var button = new JButton("Test", () => clicked = true); + + // Use reflection to invoke the protected Invoke method on Clickable + var invokeMethod = typeof(Clickable).GetMethod("Invoke", + BindingFlags.NonPublic | BindingFlags.Instance, + null, new[] { typeof(EventBase) }, null); + + // If the internal API changed, skip the click verification + if (invokeMethod == null) + { + Assert.Pass("Clickable.Invoke method not found - Unity API may have changed. Button creation verified."); + return; + } + + using (var evt = MouseDownEvent.GetPooled()) + { + invokeMethod.Invoke(button.clickable, new object[] { evt }); + } + + Assert.IsTrue(clicked); + } + + [Test] + public void Constructor_WithNullClickHandler_DoesNotThrow() + { + Assert.DoesNotThrow(() => new JButton("Test", null)); + } + + #endregion + + #region SetVariant Tests + + [Test] + public void SetVariant_Primary_AddsCorrectClass() + { + _button.SetVariant(ButtonVariant.Primary); + Assert.IsTrue(_button.ClassListContains("j-button--primary")); + } + + [Test] + public void SetVariant_Secondary_AddsCorrectClass() + { + _button.SetVariant(ButtonVariant.Secondary); + Assert.IsTrue(_button.ClassListContains("j-button--secondary")); + } + + [Test] + public void SetVariant_Success_AddsCorrectClass() + { + _button.SetVariant(ButtonVariant.Success); + Assert.IsTrue(_button.ClassListContains("j-button--success")); + } + + [Test] + public void SetVariant_Danger_AddsCorrectClass() + { + _button.SetVariant(ButtonVariant.Danger); + Assert.IsTrue(_button.ClassListContains("j-button--danger")); + } + + [Test] + public void SetVariant_Warning_AddsCorrectClass() + { + _button.SetVariant(ButtonVariant.Warning); + Assert.IsTrue(_button.ClassListContains("j-button--warning")); + } + + [Test] + public void SetVariant_RemovesPreviousVariantClass() + { + _button.SetVariant(ButtonVariant.Primary); + _button.SetVariant(ButtonVariant.Secondary); + + Assert.IsFalse(_button.ClassListContains("j-button--primary")); + Assert.IsTrue(_button.ClassListContains("j-button--secondary")); + } + + [Test] + public void SetVariant_ReturnsButtonForChaining() + { + var result = _button.SetVariant(ButtonVariant.Success); + Assert.AreSame(_button, result); + } + + [Test] + public void SetVariant_UpdatesBackgroundColor() + { + _button.SetVariant(ButtonVariant.Primary); + var primaryColor = JTheme.GetButtonColor(ButtonVariant.Primary); + + // Style may be set, check that it's the expected color + Assert.AreEqual(primaryColor, _button.style.backgroundColor.value); + } + + #endregion + + #region Variant Property Tests + + [Test] + public void Variant_Get_ReturnsCurrentVariant() + { + _button.SetVariant(ButtonVariant.Danger); + Assert.AreEqual(ButtonVariant.Danger, _button.Variant); + } + + [Test] + public void Variant_Set_CallsSetVariant() + { + _button.Variant = ButtonVariant.Warning; + Assert.IsTrue(_button.ClassListContains("j-button--warning")); + } + + #endregion + + #region WithText Tests + + [Test] + public void WithText_SetsButtonText() + { + _button.WithText("New Text"); + Assert.AreEqual("New Text", _button.text); + } + + [Test] + public void WithText_ReturnsButtonForChaining() + { + var result = _button.WithText("Test"); + Assert.AreSame(_button, result); + } + + [Test] + public void WithText_EmptyString_SetsEmptyText() + { + _button.WithText(""); + Assert.AreEqual("", _button.text); + } + + [Test] + public void WithText_NullString_SetsEmptyOrNull() + { + _button.WithText(null); + // Button.text may convert null to empty string internally + Assert.IsTrue(string.IsNullOrEmpty(_button.text)); + } + + #endregion + + #region WithClass Tests + + [Test] + public void WithClass_AddsClassName() + { + _button.WithClass("custom-class"); + Assert.IsTrue(_button.ClassListContains("custom-class")); + } + + [Test] + public void WithClass_ReturnsButtonForChaining() + { + var result = _button.WithClass("test"); + Assert.AreSame(_button, result); + } + + [Test] + public void WithClass_PreservesBaseClass() + { + _button.WithClass("custom"); + Assert.IsTrue(_button.ClassListContains("j-button")); + } + + #endregion + + #region WithEnabled Tests + + [Test] + public void WithEnabled_True_EnablesButton() + { + _button.SetEnabled(false); + _button.WithEnabled(true); + Assert.IsTrue(_button.enabledSelf); + } + + [Test] + public void WithEnabled_False_DisablesButton() + { + _button.WithEnabled(false); + Assert.IsFalse(_button.enabledSelf); + } + + [Test] + public void WithEnabled_ReturnsButtonForChaining() + { + var result = _button.WithEnabled(true); + Assert.AreSame(_button, result); + } + + #endregion + + #region FullWidth Tests + + [Test] + public void FullWidth_SetsFlexGrow() + { + _button.FullWidth(); + Assert.AreEqual(1f, _button.style.flexGrow.value); + } + + [Test] + public void FullWidth_SetsFlexShrink() + { + _button.FullWidth(); + Assert.AreEqual(1f, _button.style.flexShrink.value); + } + + [Test] + public void FullWidth_SetsMinWidth() + { + _button.FullWidth(); + Assert.AreEqual(60f, _button.style.minWidth.value.value); + } + + [Test] + public void FullWidth_ReturnsButtonForChaining() + { + var result = _button.FullWidth(); + Assert.AreSame(_button, result); + } + + #endregion + + #region Compact Tests + + [Test] + public void Compact_SetsSmallerPadding() + { + _button.Compact(); + Assert.AreEqual(2f, _button.style.paddingTop.value.value); + Assert.AreEqual(2f, _button.style.paddingBottom.value.value); + Assert.AreEqual(6f, _button.style.paddingLeft.value.value); + Assert.AreEqual(6f, _button.style.paddingRight.value.value); + } + + [Test] + public void Compact_SetsSmallerMinHeight() + { + _button.Compact(); + Assert.AreEqual(18f, _button.style.minHeight.value.value); + } + + [Test] + public void Compact_SetsSmallerFontSize() + { + _button.Compact(); + Assert.AreEqual(10f, _button.style.fontSize.value.value); + } + + [Test] + public void Compact_ReturnsButtonForChaining() + { + var result = _button.Compact(); + Assert.AreSame(_button, result); + } + + #endregion + + #region WithMinWidth Tests + + [Test] + public void WithMinWidth_SetsMinWidth() + { + _button.WithMinWidth(100f); + Assert.AreEqual(100f, _button.style.minWidth.value.value); + } + + [Test] + public void WithMinWidth_ReturnsButtonForChaining() + { + var result = _button.WithMinWidth(50f); + Assert.AreSame(_button, result); + } + + #endregion + + #region Style Application Tests + + [Test] + public void Constructor_AppliesBorderRadius() + { + Assert.AreEqual(Tokens.BorderRadius.MD, _button.style.borderTopLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _button.style.borderTopRightRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _button.style.borderBottomLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _button.style.borderBottomRightRadius.value.value); + } + + [Test] + public void Constructor_AppliesDefaultPadding() + { + Assert.AreEqual(Tokens.Spacing.Sm, _button.style.paddingTop.value.value); + Assert.AreEqual(Tokens.Spacing.Lg, _button.style.paddingRight.value.value); + Assert.AreEqual(Tokens.Spacing.Sm, _button.style.paddingBottom.value.value); + Assert.AreEqual(Tokens.Spacing.Lg, _button.style.paddingLeft.value.value); + } + + [Test] + public void Constructor_SetsZeroMargins() + { + Assert.AreEqual(0f, _button.style.marginLeft.value.value); + Assert.AreEqual(0f, _button.style.marginRight.value.value); + Assert.AreEqual(0f, _button.style.marginTop.value.value); + Assert.AreEqual(0f, _button.style.marginBottom.value.value); + } + + [Test] + public void Constructor_SetsMinHeight() + { + Assert.AreEqual(28f, _button.style.minHeight.value.value); + } + + [Test] + public void Constructor_SetsBaseFontSize() + { + Assert.AreEqual(Tokens.FontSize.Base, _button.style.fontSize.value.value); + } + + [Test] + public void Constructor_SetsZeroBorderWidths() + { + Assert.AreEqual(0f, _button.style.borderTopWidth.value); + Assert.AreEqual(0f, _button.style.borderRightWidth.value); + Assert.AreEqual(0f, _button.style.borderBottomWidth.value); + Assert.AreEqual(0f, _button.style.borderLeftWidth.value); + } + + #endregion + + #region Chaining Tests + + [Test] + public void FluentApi_CanChainMultipleMethods() + { + var button = new JButton("Start") + .WithText("Changed") + .SetVariant(ButtonVariant.Success) + .WithClass("custom") + .WithEnabled(true) + .WithMinWidth(120f); + + Assert.AreEqual("Changed", button.text); + Assert.AreEqual(ButtonVariant.Success, button.Variant); + Assert.IsTrue(button.ClassListContains("custom")); + Assert.IsTrue(button.enabledSelf); + Assert.AreEqual(120f, button.style.minWidth.value.value); + } + + #endregion + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonTests.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonTests.cs.meta new file mode 100644 index 00000000..5ee31f93 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JButtonTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9230a46f4806046f4ac8a45f0c27b41f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JIconButtonTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JIconButtonTests.cs new file mode 100644 index 00000000..5f2e6e58 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JIconButtonTests.cs @@ -0,0 +1,263 @@ +// JIconButtonTests.cs +// EditMode unit tests for JIconButton + +using System; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.UIElements; +using JEngine.UI.Editor.Components.Button; +using JEngine.UI.Editor.Theming; + +namespace JEngine.UI.Tests.Editor.Components.Button +{ + [TestFixture] + public class JIconButtonTests + { + private JIconButton _iconButton; + + [SetUp] + public void SetUp() + { + _iconButton = new JIconButton("X"); + } + + #region Constructor Tests + + [Test] + public void Constructor_AddsBaseClass() + { + Assert.IsTrue(_iconButton.ClassListContains("j-icon-button")); + } + + [Test] + public void Constructor_SetsText() + { + Assert.AreEqual("X", _iconButton.text); + } + + [Test] + public void Constructor_WithTooltip_SetsTooltip() + { + var button = new JIconButton("X", tooltip: "Close"); + Assert.AreEqual("Close", button.tooltip); + } + + [Test] + public void Constructor_WithClickHandler_RegistersCallback() + { + bool clicked = false; + var button = new JIconButton("X", () => clicked = true); + + // Use reflection to invoke the protected Invoke method on Clickable + var invokeMethod = typeof(Clickable).GetMethod("Invoke", + BindingFlags.NonPublic | BindingFlags.Instance, + null, new[] { typeof(EventBase) }, null); + + // If the internal API changed, skip the click verification + if (invokeMethod == null) + { + Assert.Pass("Clickable.Invoke method not found - Unity API may have changed. Button creation verified."); + return; + } + + using (var evt = MouseDownEvent.GetPooled()) + { + invokeMethod.Invoke(button.clickable, new object[] { evt }); + } + + Assert.IsTrue(clicked); + } + + [Test] + public void Constructor_WithNullClickHandler_DoesNotThrow() + { + Assert.DoesNotThrow(() => new JIconButton("X", null)); + } + + [Test] + public void Constructor_SetsWidth() + { + Assert.AreEqual(22f, _iconButton.style.width.value.value); + } + + [Test] + public void Constructor_SetsHeight() + { + Assert.AreEqual(18f, _iconButton.style.height.value.value); + } + + [Test] + public void Constructor_SetsMinWidth() + { + Assert.AreEqual(18f, _iconButton.style.minWidth.value.value); + } + + [Test] + public void Constructor_SetsMinHeight() + { + Assert.AreEqual(18f, _iconButton.style.minHeight.value.value); + } + + [Test] + public void Constructor_SetsTransparentBackground() + { + Assert.AreEqual(Color.clear, _iconButton.style.backgroundColor.value); + } + + [Test] + public void Constructor_SetsZeroBorderWidths() + { + Assert.AreEqual(0f, _iconButton.style.borderTopWidth.value); + Assert.AreEqual(0f, _iconButton.style.borderRightWidth.value); + Assert.AreEqual(0f, _iconButton.style.borderBottomWidth.value); + Assert.AreEqual(0f, _iconButton.style.borderLeftWidth.value); + } + + [Test] + public void Constructor_SetsBorderRadius() + { + Assert.AreEqual(Tokens.BorderRadius.Sm, _iconButton.style.borderTopLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _iconButton.style.borderTopRightRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _iconButton.style.borderBottomLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _iconButton.style.borderBottomRightRadius.value.value); + } + + [Test] + public void Constructor_SetsXsFontSize() + { + Assert.AreEqual(Tokens.FontSize.Xs, _iconButton.style.fontSize.value.value); + } + + [Test] + public void Constructor_SetsMutedTextColor() + { + Assert.AreEqual(Tokens.Colors.TextMuted, _iconButton.style.color.value); + } + + [Test] + public void Constructor_SetsZeroPadding() + { + Assert.AreEqual(0f, _iconButton.style.paddingLeft.value.value); + Assert.AreEqual(0f, _iconButton.style.paddingRight.value.value); + Assert.AreEqual(0f, _iconButton.style.paddingTop.value.value); + Assert.AreEqual(0f, _iconButton.style.paddingBottom.value.value); + } + + [Test] + public void Constructor_SetsLeftMargin() + { + Assert.AreEqual(2f, _iconButton.style.marginLeft.value.value); + } + + #endregion + + #region WithTooltip Tests + + [Test] + public void WithTooltip_SetsTooltip() + { + _iconButton.WithTooltip("Test tooltip"); + Assert.AreEqual("Test tooltip", _iconButton.tooltip); + } + + [Test] + public void WithTooltip_ReturnsButtonForChaining() + { + var result = _iconButton.WithTooltip("Test"); + Assert.AreSame(_iconButton, result); + } + + [Test] + public void WithTooltip_CanOverwrite() + { + _iconButton.WithTooltip("First"); + _iconButton.WithTooltip("Second"); + Assert.AreEqual("Second", _iconButton.tooltip); + } + + [Test] + public void WithTooltip_EmptyString_SetsEmpty() + { + _iconButton.WithTooltip("Something"); + _iconButton.WithTooltip(""); + Assert.AreEqual("", _iconButton.tooltip); + } + + #endregion + + #region WithSize Tests + + [Test] + public void WithSize_SetsWidth() + { + _iconButton.WithSize(30f, 25f); + Assert.AreEqual(30f, _iconButton.style.width.value.value); + } + + [Test] + public void WithSize_SetsHeight() + { + _iconButton.WithSize(30f, 25f); + Assert.AreEqual(25f, _iconButton.style.height.value.value); + } + + [Test] + public void WithSize_ReturnsButtonForChaining() + { + var result = _iconButton.WithSize(20f, 20f); + Assert.AreSame(_iconButton, result); + } + + [Test] + public void WithSize_CanSetSquare() + { + _iconButton.WithSize(24f, 24f); + Assert.AreEqual(24f, _iconButton.style.width.value.value); + Assert.AreEqual(24f, _iconButton.style.height.value.value); + } + + #endregion + + #region Different Text Content Tests + + [Test] + public void Constructor_WithEmoji_Works() + { + var button = new JIconButton("🔍"); + Assert.AreEqual("🔍", button.text); + } + + [Test] + public void Constructor_WithMultipleChars_Works() + { + var button = new JIconButton("..."); + Assert.AreEqual("...", button.text); + } + + [Test] + public void Constructor_WithEmptyString_Works() + { + var button = new JIconButton(""); + Assert.AreEqual("", button.text); + } + + #endregion + + #region Chaining Tests + + [Test] + public void FluentApi_CanChainMultipleMethods() + { + _iconButton + .WithTooltip("Close") + .WithSize(24f, 24f); + + Assert.AreEqual("Close", _iconButton.tooltip); + Assert.AreEqual(24f, _iconButton.style.width.value.value); + Assert.AreEqual(24f, _iconButton.style.height.value.value); + } + + #endregion + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JIconButtonTests.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JIconButtonTests.cs.meta new file mode 100644 index 00000000..853ab5ec --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JIconButtonTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af5b2d5edb8764c819d7a1ffd683a335 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JToggleButtonTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JToggleButtonTests.cs new file mode 100644 index 00000000..75601362 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JToggleButtonTests.cs @@ -0,0 +1,405 @@ +// JToggleButtonTests.cs +// EditMode unit tests for JToggleButton + +using NUnit.Framework; +using UnityEngine.UIElements; +using JEngine.UI.Editor.Components.Button; +using JEngine.UI.Editor.Theming; + +namespace JEngine.UI.Tests.Editor.Components.Button +{ + [TestFixture] + public class JToggleButtonTests + { + private JToggleButton _toggleButton; + + [SetUp] + public void SetUp() + { + _toggleButton = new JToggleButton("On", "Off"); + } + + #region Constructor Tests + + [Test] + public void Constructor_AddsBaseClass() + { + Assert.IsTrue(_toggleButton.ClassListContains("j-toggle-button")); + } + + [Test] + public void Constructor_Default_ValueIsFalse() + { + Assert.IsFalse(_toggleButton.Value); + } + + [Test] + public void Constructor_Default_DisplaysOffText() + { + Assert.AreEqual("Off", _toggleButton.text); + } + + [Test] + public void Constructor_WithTrueValue_ValueIsTrue() + { + var button = new JToggleButton("On", "Off", true); + Assert.IsTrue(button.Value); + } + + [Test] + public void Constructor_WithTrueValue_DisplaysOnText() + { + var button = new JToggleButton("On", "Off", true); + Assert.AreEqual("On", button.text); + } + + [Test] + public void Constructor_DefaultOnVariant_IsSuccess() + { + // Create button with true to test on variant + var button = new JToggleButton("On", "Off", true); + var successColor = JTheme.GetButtonColor(ButtonVariant.Success); + Assert.AreEqual(successColor, button.style.backgroundColor.value); + } + + [Test] + public void Constructor_DefaultOffVariant_IsDanger() + { + var dangerColor = JTheme.GetButtonColor(ButtonVariant.Danger); + Assert.AreEqual(dangerColor, _toggleButton.style.backgroundColor.value); + } + + [Test] + public void Constructor_WithCustomVariants_AppliesCorrectly() + { + var button = new JToggleButton("On", "Off", false, ButtonVariant.Primary, ButtonVariant.Secondary); + var secondaryColor = JTheme.GetButtonColor(ButtonVariant.Secondary); + Assert.AreEqual(secondaryColor, button.style.backgroundColor.value); + } + + [Test] + public void Constructor_WithCallback_RegistersCallback() + { + bool callbackInvoked = false; + var button = new JToggleButton("On", "Off", false, ButtonVariant.Success, ButtonVariant.Danger, _ => callbackInvoked = true); + + button.Value = true; + + Assert.IsTrue(callbackInvoked); + } + + #endregion + + #region Value Property Tests + + [Test] + public void Value_Get_ReturnsCurrentValue() + { + Assert.IsFalse(_toggleButton.Value); + } + + [Test] + public void Value_Set_True_UpdatesValue() + { + _toggleButton.Value = true; + Assert.IsTrue(_toggleButton.Value); + } + + [Test] + public void Value_Set_False_UpdatesValue() + { + var button = new JToggleButton("On", "Off", true); + button.Value = false; + Assert.IsFalse(button.Value); + } + + [Test] + public void Value_Set_True_UpdatesText() + { + _toggleButton.Value = true; + Assert.AreEqual("On", _toggleButton.text); + } + + [Test] + public void Value_Set_False_UpdatesText() + { + var button = new JToggleButton("On", "Off", true); + button.Value = false; + Assert.AreEqual("Off", button.text); + } + + [Test] + public void Value_Set_True_UpdatesBackgroundColor() + { + _toggleButton.Value = true; + var successColor = JTheme.GetButtonColor(ButtonVariant.Success); + Assert.AreEqual(successColor, _toggleButton.style.backgroundColor.value); + } + + [Test] + public void Value_Set_False_UpdatesBackgroundColor() + { + var button = new JToggleButton("On", "Off", true); + button.Value = false; + var dangerColor = JTheme.GetButtonColor(ButtonVariant.Danger); + Assert.AreEqual(dangerColor, button.style.backgroundColor.value); + } + + #endregion + + #region SetValue Tests + + [Test] + public void SetValue_WithNotify_InvokesCallback() + { + bool callbackInvoked = false; + _toggleButton.OnValueChanged = _ => callbackInvoked = true; + + _toggleButton.SetValue(true, notify: true); + + Assert.IsTrue(callbackInvoked); + } + + [Test] + public void SetValue_WithoutNotify_DoesNotInvokeCallback() + { + bool callbackInvoked = false; + _toggleButton.OnValueChanged = _ => callbackInvoked = true; + + _toggleButton.SetValue(true, notify: false); + + Assert.IsFalse(callbackInvoked); + } + + [Test] + public void SetValue_WithoutNotify_StillUpdatesValue() + { + _toggleButton.SetValue(true, notify: false); + Assert.IsTrue(_toggleButton.Value); + } + + [Test] + public void SetValue_WithoutNotify_StillUpdatesText() + { + _toggleButton.SetValue(true, notify: false); + Assert.AreEqual("On", _toggleButton.text); + } + + #endregion + + #region OnValueChanged Property Tests + + [Test] + public void OnValueChanged_Get_ReturnsCallback() + { + System.Action callback = _ => { }; + _toggleButton.OnValueChanged = callback; + + Assert.AreSame(callback, _toggleButton.OnValueChanged); + } + + [Test] + public void OnValueChanged_Set_ReplacesCallback() + { + int firstCount = 0; + int secondCount = 0; + + _toggleButton.OnValueChanged = _ => firstCount++; + _toggleButton.OnValueChanged = _ => secondCount++; + + _toggleButton.Value = true; + + Assert.AreEqual(0, firstCount); + Assert.AreEqual(1, secondCount); + } + + #endregion + + #region WithOnText Tests + + [Test] + public void WithOnText_UpdatesOnText() + { + _toggleButton.WithOnText("Enabled"); + _toggleButton.Value = true; + + Assert.AreEqual("Enabled", _toggleButton.text); + } + + [Test] + public void WithOnText_ReturnsButtonForChaining() + { + var result = _toggleButton.WithOnText("New"); + Assert.AreSame(_toggleButton, result); + } + + #endregion + + #region WithOffText Tests + + [Test] + public void WithOffText_UpdatesOffText() + { + _toggleButton.WithOffText("Disabled"); + Assert.AreEqual("Disabled", _toggleButton.text); + } + + [Test] + public void WithOffText_ReturnsButtonForChaining() + { + var result = _toggleButton.WithOffText("New"); + Assert.AreSame(_toggleButton, result); + } + + #endregion + + #region WithOnVariant Tests + + [Test] + public void WithOnVariant_UpdatesOnVariant() + { + _toggleButton.WithOnVariant(ButtonVariant.Primary); + _toggleButton.Value = true; + + var primaryColor = JTheme.GetButtonColor(ButtonVariant.Primary); + Assert.AreEqual(primaryColor, _toggleButton.style.backgroundColor.value); + } + + [Test] + public void WithOnVariant_ReturnsButtonForChaining() + { + var result = _toggleButton.WithOnVariant(ButtonVariant.Primary); + Assert.AreSame(_toggleButton, result); + } + + #endregion + + #region WithOffVariant Tests + + [Test] + public void WithOffVariant_UpdatesOffVariant() + { + _toggleButton.WithOffVariant(ButtonVariant.Secondary); + + var secondaryColor = JTheme.GetButtonColor(ButtonVariant.Secondary); + Assert.AreEqual(secondaryColor, _toggleButton.style.backgroundColor.value); + } + + [Test] + public void WithOffVariant_ReturnsButtonForChaining() + { + var result = _toggleButton.WithOffVariant(ButtonVariant.Secondary); + Assert.AreSame(_toggleButton, result); + } + + #endregion + + #region FullWidth Tests + + [Test] + public void FullWidth_SetsFlexGrow() + { + _toggleButton.FullWidth(); + Assert.AreEqual(1f, _toggleButton.style.flexGrow.value); + } + + [Test] + public void FullWidth_SetsMaxHeight() + { + _toggleButton.FullWidth(); + Assert.AreEqual(24f, _toggleButton.style.maxHeight.value.value); + } + + [Test] + public void FullWidth_ReturnsButtonForChaining() + { + var result = _toggleButton.FullWidth(); + Assert.AreSame(_toggleButton, result); + } + + #endregion + + #region WithClass Tests + + [Test] + public void WithClass_AddsClassName() + { + _toggleButton.WithClass("custom-class"); + Assert.IsTrue(_toggleButton.ClassListContains("custom-class")); + } + + [Test] + public void WithClass_ReturnsButtonForChaining() + { + var result = _toggleButton.WithClass("test"); + Assert.AreSame(_toggleButton, result); + } + + [Test] + public void WithClass_PreservesBaseClass() + { + _toggleButton.WithClass("custom"); + Assert.IsTrue(_toggleButton.ClassListContains("j-toggle-button")); + } + + #endregion + + #region Style Tests + + [Test] + public void Constructor_SetsBorderRadius() + { + Assert.AreEqual(Tokens.BorderRadius.Sm, _toggleButton.style.borderTopLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _toggleButton.style.borderTopRightRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _toggleButton.style.borderBottomLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _toggleButton.style.borderBottomRightRadius.value.value); + } + + [Test] + public void Constructor_SetsZeroMargins() + { + Assert.AreEqual(0f, _toggleButton.style.marginLeft.value.value); + Assert.AreEqual(0f, _toggleButton.style.marginRight.value.value); + Assert.AreEqual(0f, _toggleButton.style.marginTop.value.value); + Assert.AreEqual(0f, _toggleButton.style.marginBottom.value.value); + } + + [Test] + public void Constructor_SetsMinAndMaxHeight() + { + Assert.AreEqual(22f, _toggleButton.style.minHeight.value.value); + Assert.AreEqual(24f, _toggleButton.style.maxHeight.value.value); + } + + [Test] + public void Constructor_SetsZeroBorderWidths() + { + Assert.AreEqual(0f, _toggleButton.style.borderTopWidth.value); + Assert.AreEqual(0f, _toggleButton.style.borderRightWidth.value); + Assert.AreEqual(0f, _toggleButton.style.borderBottomWidth.value); + Assert.AreEqual(0f, _toggleButton.style.borderLeftWidth.value); + } + + #endregion + + #region Chaining Tests + + [Test] + public void FluentApi_CanChainMultipleMethods() + { + var result = _toggleButton + .WithOnText("Active") + .WithOffText("Inactive") + .WithOnVariant(ButtonVariant.Primary) + .WithOffVariant(ButtonVariant.Secondary) + .WithClass("custom") + .FullWidth(); + + Assert.AreSame(_toggleButton, result); + Assert.IsTrue(_toggleButton.ClassListContains("custom")); + Assert.AreEqual(1f, _toggleButton.style.flexGrow.value); + } + + #endregion + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JToggleButtonTests.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JToggleButtonTests.cs.meta new file mode 100644 index 00000000..c62091d8 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Button/JToggleButtonTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1bd1d913f7ed8477cb8b04dde24c8d6d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback.meta new file mode 100644 index 00000000..c38f31d2 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0863dbe9f596641faa1b9d819c9c3b5a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JLogViewTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JLogViewTests.cs new file mode 100644 index 00000000..2ad5fd3e --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JLogViewTests.cs @@ -0,0 +1,386 @@ +// JLogViewTests.cs +// EditMode unit tests for JLogView + +using NUnit.Framework; +using UnityEngine.UIElements; +using JEngine.UI.Editor.Components.Feedback; +using JEngine.UI.Editor.Theming; + +namespace JEngine.UI.Tests.Editor.Components.Feedback +{ + [TestFixture] + public class JLogViewTests + { + private JLogView _logView; + + [SetUp] + public void SetUp() + { + _logView = new JLogView(); + } + + #region Constructor Tests + + [Test] + public void Constructor_AddsBaseClass() + { + Assert.IsTrue(_logView.ClassListContains("j-log-view")); + } + + [Test] + public void Constructor_Default_MaxLinesIs100() + { + Assert.AreEqual(100, _logView.MaxLines); + } + + [Test] + public void Constructor_WithMaxLines_SetsMaxLines() + { + var log = new JLogView(50); + Assert.AreEqual(50, log.MaxLines); + } + + [Test] + public void Constructor_SetsInputBackgroundColor() + { + Assert.AreEqual(Tokens.Colors.BgInput, _logView.style.backgroundColor.value); + } + + [Test] + public void Constructor_SetsBorderColors() + { + Assert.AreEqual(Tokens.Colors.Border, _logView.style.borderTopColor.value); + Assert.AreEqual(Tokens.Colors.Border, _logView.style.borderRightColor.value); + Assert.AreEqual(Tokens.Colors.Border, _logView.style.borderBottomColor.value); + Assert.AreEqual(Tokens.Colors.Border, _logView.style.borderLeftColor.value); + } + + [Test] + public void Constructor_SetsBorderWidths() + { + Assert.AreEqual(1f, _logView.style.borderTopWidth.value); + Assert.AreEqual(1f, _logView.style.borderRightWidth.value); + Assert.AreEqual(1f, _logView.style.borderBottomWidth.value); + Assert.AreEqual(1f, _logView.style.borderLeftWidth.value); + } + + [Test] + public void Constructor_SetsBorderRadius() + { + Assert.AreEqual(Tokens.BorderRadius.MD, _logView.style.borderTopLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _logView.style.borderTopRightRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _logView.style.borderBottomLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _logView.style.borderBottomRightRadius.value.value); + } + + [Test] + public void Constructor_SetsMinHeight() + { + Assert.AreEqual(100f, _logView.style.minHeight.value.value); + } + + [Test] + public void Constructor_SetsMaxHeight() + { + Assert.AreEqual(300f, _logView.style.maxHeight.value.value); + } + + [Test] + public void Constructor_SetsPadding() + { + Assert.AreEqual(Tokens.Spacing.MD, _logView.style.paddingTop.value.value); + Assert.AreEqual(Tokens.Spacing.MD, _logView.style.paddingRight.value.value); + Assert.AreEqual(Tokens.Spacing.MD, _logView.style.paddingBottom.value.value); + Assert.AreEqual(Tokens.Spacing.MD, _logView.style.paddingLeft.value.value); + } + + [Test] + public void Constructor_CreatesScrollView() + { + Assert.IsNotNull(_logView.ScrollView); + } + + [Test] + public void Constructor_ScrollViewHasCorrectClass() + { + Assert.IsTrue(_logView.ScrollView.ClassListContains("j-log-view__scroll")); + } + + [Test] + public void Constructor_ScrollViewHasFlexGrow() + { + Assert.AreEqual(1f, _logView.ScrollView.style.flexGrow.value); + } + + #endregion + + #region MaxLines Property Tests + + [Test] + public void MaxLines_Get_ReturnsCurrentMaxLines() + { + var log = new JLogView(75); + Assert.AreEqual(75, log.MaxLines); + } + + [Test] + public void MaxLines_Set_UpdatesMaxLines() + { + _logView.MaxLines = 200; + Assert.AreEqual(200, _logView.MaxLines); + } + + [Test] + public void MaxLines_SetToZero_UnlimitedLines() + { + _logView.MaxLines = 0; + Assert.AreEqual(0, _logView.MaxLines); + } + + #endregion + + #region Log Tests + + [Test] + public void Log_AddsEntryToScrollView() + { + _logView.Log("Test message"); + Assert.AreEqual(1, _logView.ScrollView.childCount); + } + + [Test] + public void Log_MultipleMessages_AddsAllEntries() + { + _logView.Log("Message 1"); + _logView.Log("Message 2"); + _logView.Log("Message 3"); + + Assert.AreEqual(3, _logView.ScrollView.childCount); + } + + [Test] + public void Log_ReturnsLogViewForChaining() + { + var result = _logView.Log("Test"); + Assert.AreSame(_logView, result); + } + + [Test] + public void Log_DefaultNotError_AddsInfoClass() + { + _logView.Log("Info message"); + var entry = _logView.ScrollView[0]; + Assert.IsTrue(entry.ClassListContains("j-log-view__entry--info")); + } + + [Test] + public void Log_IsError_AddsErrorClass() + { + _logView.Log("Error message", isError: true); + var entry = _logView.ScrollView[0]; + Assert.IsTrue(entry.ClassListContains("j-log-view__entry--error")); + } + + [Test] + public void Log_EntryHasCorrectBaseClass() + { + _logView.Log("Test"); + var entry = _logView.ScrollView[0]; + Assert.IsTrue(entry.ClassListContains("j-log-view__entry")); + } + + [Test] + public void Log_ExceedsMaxLines_RemovesOldestEntries() + { + var log = new JLogView(3); + + log.Log("Message 1"); + log.Log("Message 2"); + log.Log("Message 3"); + log.Log("Message 4"); + + Assert.AreEqual(3, log.ScrollView.childCount); + } + + [Test] + public void Log_UnlimitedMaxLines_DoesNotRemoveEntries() + { + var log = new JLogView(0); // Unlimited + + for (int i = 0; i < 150; i++) + { + log.Log($"Message {i}"); + } + + Assert.AreEqual(150, log.ScrollView.childCount); + } + + #endregion + + #region LogInfo Tests + + [Test] + public void LogInfo_AddsEntry() + { + _logView.LogInfo("Info message"); + Assert.AreEqual(1, _logView.ScrollView.childCount); + } + + [Test] + public void LogInfo_AddsInfoClass() + { + _logView.LogInfo("Info message"); + var entry = _logView.ScrollView[0]; + Assert.IsTrue(entry.ClassListContains("j-log-view__entry--info")); + } + + [Test] + public void LogInfo_ReturnsLogViewForChaining() + { + var result = _logView.LogInfo("Test"); + Assert.AreSame(_logView, result); + } + + #endregion + + #region LogError Tests + + [Test] + public void LogError_AddsEntry() + { + _logView.LogError("Error message"); + Assert.AreEqual(1, _logView.ScrollView.childCount); + } + + [Test] + public void LogError_AddsErrorClass() + { + _logView.LogError("Error message"); + var entry = _logView.ScrollView[0]; + Assert.IsTrue(entry.ClassListContains("j-log-view__entry--error")); + } + + [Test] + public void LogError_ReturnsLogViewForChaining() + { + var result = _logView.LogError("Test"); + Assert.AreSame(_logView, result); + } + + #endregion + + #region Clear Tests + + [Test] + public void Clear_RemovesAllEntries() + { + _logView.Log("Message 1"); + _logView.Log("Message 2"); + _logView.Log("Message 3"); + + _logView.Clear(); + + Assert.AreEqual(0, _logView.ScrollView.childCount); + } + + [Test] + public void Clear_ReturnsLogViewForChaining() + { + var result = _logView.Clear(); + Assert.AreSame(_logView, result); + } + + [Test] + public void Clear_ResetsLineCount() + { + var log = new JLogView(3); + log.Log("1"); + log.Log("2"); + log.Log("3"); + + log.Clear(); + + // After clearing, we should be able to add 3 more without removal + log.Log("A"); + log.Log("B"); + log.Log("C"); + + Assert.AreEqual(3, log.ScrollView.childCount); + } + + #endregion + + #region WithMinHeight Tests + + [Test] + public void WithMinHeight_SetsMinHeight() + { + _logView.WithMinHeight(150f); + Assert.AreEqual(150f, _logView.style.minHeight.value.value); + } + + [Test] + public void WithMinHeight_ReturnsLogViewForChaining() + { + var result = _logView.WithMinHeight(200f); + Assert.AreSame(_logView, result); + } + + #endregion + + #region WithMaxHeight Tests + + [Test] + public void WithMaxHeight_SetsMaxHeight() + { + _logView.WithMaxHeight(500f); + Assert.AreEqual(500f, _logView.style.maxHeight.value.value); + } + + [Test] + public void WithMaxHeight_ReturnsLogViewForChaining() + { + var result = _logView.WithMaxHeight(400f); + Assert.AreSame(_logView, result); + } + + #endregion + + #region ScrollView Property Tests + + [Test] + public void ScrollView_ReturnsScrollViewElement() + { + Assert.IsInstanceOf(_logView.ScrollView); + } + + [Test] + public void ScrollView_IsSameInstanceOnMultipleCalls() + { + var sv1 = _logView.ScrollView; + var sv2 = _logView.ScrollView; + Assert.AreSame(sv1, sv2); + } + + #endregion + + #region Chaining Tests + + [Test] + public void FluentApi_CanChainMultipleMethods() + { + _logView + .WithMinHeight(150f) + .WithMaxHeight(400f) + .LogInfo("Info") + .LogError("Error") + .Log("Normal"); + + Assert.AreEqual(150f, _logView.style.minHeight.value.value); + Assert.AreEqual(400f, _logView.style.maxHeight.value.value); + Assert.AreEqual(3, _logView.ScrollView.childCount); + } + + #endregion + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JLogViewTests.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JLogViewTests.cs.meta new file mode 100644 index 00000000..cd7964c8 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JLogViewTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bb48b0d84fa9c4788a30e5c8081767f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JProgressBarTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JProgressBarTests.cs new file mode 100644 index 00000000..48217fbf --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JProgressBarTests.cs @@ -0,0 +1,315 @@ +// JProgressBarTests.cs +// EditMode unit tests for JProgressBar + +using NUnit.Framework; +using UnityEngine; +using UnityEngine.UIElements; +using JEngine.UI.Editor.Components.Feedback; +using JEngine.UI.Editor.Theming; + +namespace JEngine.UI.Tests.Editor.Components.Feedback +{ + [TestFixture] + public class JProgressBarTests + { + private JProgressBar _progressBar; + + [SetUp] + public void SetUp() + { + _progressBar = new JProgressBar(); + } + + #region Constructor Tests + + [Test] + public void Constructor_AddsBaseClass() + { + Assert.IsTrue(_progressBar.ClassListContains("j-progress-bar")); + } + + [Test] + public void Constructor_Default_ProgressIsZero() + { + Assert.AreEqual(0f, _progressBar.Progress); + } + + [Test] + public void Constructor_WithInitialProgress_SetsProgress() + { + var bar = new JProgressBar(0.5f); + Assert.AreEqual(0.5f, bar.Progress); + } + + [Test] + public void Constructor_SetsHeight() + { + Assert.AreEqual(8f, _progressBar.style.height.value.value); + } + + [Test] + public void Constructor_SetsSurfaceBackgroundColor() + { + Assert.AreEqual(Tokens.Colors.BgSurface, _progressBar.style.backgroundColor.value); + } + + [Test] + public void Constructor_SetsBorderRadius() + { + Assert.AreEqual(Tokens.BorderRadius.Sm, _progressBar.style.borderTopLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _progressBar.style.borderTopRightRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _progressBar.style.borderBottomLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.Sm, _progressBar.style.borderBottomRightRadius.value.value); + } + + [Test] + public void Constructor_SetsOverflowHidden() + { + Assert.AreEqual(Overflow.Hidden, _progressBar.style.overflow.value); + } + + [Test] + public void Constructor_CreatesFillElement() + { + Assert.IsNotNull(_progressBar.Fill); + } + + [Test] + public void Constructor_FillHasCorrectClass() + { + Assert.IsTrue(_progressBar.Fill.ClassListContains("j-progress-bar__fill")); + } + + [Test] + public void Constructor_FillHasPrimaryColor() + { + Assert.AreEqual(Tokens.Colors.Primary, _progressBar.Fill.style.backgroundColor.value); + } + + #endregion + + #region Progress Property Tests + + [Test] + public void Progress_Get_ReturnsCurrentProgress() + { + var bar = new JProgressBar(0.75f); + Assert.AreEqual(0.75f, bar.Progress); + } + + [Test] + public void Progress_Set_UpdatesProgress() + { + _progressBar.Progress = 0.5f; + Assert.AreEqual(0.5f, _progressBar.Progress); + } + + [Test] + public void Progress_Set_Clamps_ToZero() + { + _progressBar.Progress = -0.5f; + Assert.AreEqual(0f, _progressBar.Progress); + } + + [Test] + public void Progress_Set_Clamps_ToOne() + { + _progressBar.Progress = 1.5f; + Assert.AreEqual(1f, _progressBar.Progress); + } + + [Test] + public void Progress_Set_UpdatesFillWidth() + { + _progressBar.Progress = 0.5f; + Assert.AreEqual(50f, _progressBar.Fill.style.width.value.value); + } + + [Test] + public void Progress_Set_Zero_FillWidthIsZero() + { + _progressBar.Progress = 0f; + Assert.AreEqual(0f, _progressBar.Fill.style.width.value.value); + } + + [Test] + public void Progress_Set_Full_FillWidthIs100Percent() + { + _progressBar.Progress = 1f; + Assert.AreEqual(100f, _progressBar.Fill.style.width.value.value); + } + + #endregion + + #region SetProgress Tests + + [Test] + public void SetProgress_UpdatesProgress() + { + _progressBar.SetProgress(0.75f); + Assert.AreEqual(0.75f, _progressBar.Progress); + } + + [Test] + public void SetProgress_ReturnsProgressBarForChaining() + { + var result = _progressBar.SetProgress(0.5f); + Assert.AreSame(_progressBar, result); + } + + [Test] + public void SetProgress_ClampsBelowZero() + { + _progressBar.SetProgress(-1f); + Assert.AreEqual(0f, _progressBar.Progress); + } + + [Test] + public void SetProgress_ClampsAboveOne() + { + _progressBar.SetProgress(2f); + Assert.AreEqual(1f, _progressBar.Progress); + } + + #endregion + + #region WithSuccessOnComplete Tests + + [Test] + public void WithSuccessOnComplete_WhenNotComplete_KeepsPrimaryColor() + { + _progressBar.Progress = 0.5f; + _progressBar.WithSuccessOnComplete(true); + + Assert.AreEqual(Tokens.Colors.Primary, _progressBar.Fill.style.backgroundColor.value); + } + + [Test] + public void WithSuccessOnComplete_WhenComplete_UsesSuccessColor() + { + _progressBar.Progress = 1f; + _progressBar.WithSuccessOnComplete(true); + // Need to set progress again to trigger color update + _progressBar.SetProgress(1f); + + Assert.AreEqual(Tokens.Colors.Success, _progressBar.Fill.style.backgroundColor.value); + } + + [Test] + public void WithSuccessOnComplete_ReturnsProgressBarForChaining() + { + var result = _progressBar.WithSuccessOnComplete(true); + Assert.AreSame(_progressBar, result); + } + + [Test] + public void WithSuccessOnComplete_False_UsesPrimaryColor() + { + _progressBar.Progress = 1f; + _progressBar.WithSuccessOnComplete(false); + + Assert.AreEqual(Tokens.Colors.Primary, _progressBar.Fill.style.backgroundColor.value); + } + + #endregion + + #region WithHeight Tests + + [Test] + public void WithHeight_SetsHeight() + { + _progressBar.WithHeight(16f); + Assert.AreEqual(16f, _progressBar.style.height.value.value); + } + + [Test] + public void WithHeight_ReturnsProgressBarForChaining() + { + var result = _progressBar.WithHeight(12f); + Assert.AreSame(_progressBar, result); + } + + #endregion + + #region WithColor Tests + + [Test] + public void WithColor_SetsFillColor() + { + _progressBar.WithColor(Color.red); + Assert.AreEqual(Color.red, _progressBar.Fill.style.backgroundColor.value); + } + + [Test] + public void WithColor_ReturnsProgressBarForChaining() + { + var result = _progressBar.WithColor(Color.blue); + Assert.AreSame(_progressBar, result); + } + + #endregion + + #region WithVariant Tests + + [Test] + public void WithVariant_Primary_SetsPrimaryColor() + { + _progressBar.WithVariant(ButtonVariant.Primary); + Assert.AreEqual(JTheme.GetButtonColor(ButtonVariant.Primary), _progressBar.Fill.style.backgroundColor.value); + } + + [Test] + public void WithVariant_Secondary_SetsSecondaryColor() + { + _progressBar.WithVariant(ButtonVariant.Secondary); + Assert.AreEqual(JTheme.GetButtonColor(ButtonVariant.Secondary), _progressBar.Fill.style.backgroundColor.value); + } + + [Test] + public void WithVariant_Success_SetsSuccessColor() + { + _progressBar.WithVariant(ButtonVariant.Success); + Assert.AreEqual(JTheme.GetButtonColor(ButtonVariant.Success), _progressBar.Fill.style.backgroundColor.value); + } + + [Test] + public void WithVariant_Danger_SetsDangerColor() + { + _progressBar.WithVariant(ButtonVariant.Danger); + Assert.AreEqual(JTheme.GetButtonColor(ButtonVariant.Danger), _progressBar.Fill.style.backgroundColor.value); + } + + [Test] + public void WithVariant_Warning_SetsWarningColor() + { + _progressBar.WithVariant(ButtonVariant.Warning); + Assert.AreEqual(JTheme.GetButtonColor(ButtonVariant.Warning), _progressBar.Fill.style.backgroundColor.value); + } + + [Test] + public void WithVariant_ReturnsProgressBarForChaining() + { + var result = _progressBar.WithVariant(ButtonVariant.Success); + Assert.AreSame(_progressBar, result); + } + + #endregion + + #region Chaining Tests + + [Test] + public void FluentApi_CanChainMultipleMethods() + { + _progressBar + .SetProgress(0.75f) + .WithHeight(12f) + .WithVariant(ButtonVariant.Success) + .WithSuccessOnComplete(true); + + Assert.AreEqual(0.75f, _progressBar.Progress); + Assert.AreEqual(12f, _progressBar.style.height.value.value); + } + + #endregion + } +} diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JProgressBarTests.cs.meta b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JProgressBarTests.cs.meta new file mode 100644 index 00000000..4f823c4a --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JProgressBarTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f0dc496e098f74e778753e51c5d588d2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JStatusBarTests.cs b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JStatusBarTests.cs new file mode 100644 index 00000000..8a92cdd3 --- /dev/null +++ b/UnityProject/Packages/com.jasonxudeveloper.jengine.ui/Tests/Editor/Components/Feedback/JStatusBarTests.cs @@ -0,0 +1,293 @@ +// JStatusBarTests.cs +// EditMode unit tests for JStatusBar + +using NUnit.Framework; +using UnityEngine.UIElements; +using JEngine.UI.Editor.Components.Feedback; +using JEngine.UI.Editor.Theming; + +namespace JEngine.UI.Tests.Editor.Components.Feedback +{ + [TestFixture] + public class JStatusBarTests + { + private JStatusBar _statusBar; + + [SetUp] + public void SetUp() + { + _statusBar = new JStatusBar("Test message"); + } + + #region Constructor Tests + + [Test] + public void Constructor_AddsBaseClass() + { + Assert.IsTrue(_statusBar.ClassListContains("j-status-bar")); + } + + [Test] + public void Constructor_SetsText() + { + Assert.AreEqual("Test message", _statusBar.Text); + } + + [Test] + public void Constructor_Default_StatusIsInfo() + { + var bar = new JStatusBar(); + Assert.AreEqual(StatusType.Info, bar.Status); + } + + [Test] + public void Constructor_WithStatus_SetsStatus() + { + var bar = new JStatusBar("msg", StatusType.Error); + Assert.AreEqual(StatusType.Error, bar.Status); + } + + [Test] + public void Constructor_SetsRowDirection() + { + Assert.AreEqual(FlexDirection.Row, _statusBar.style.flexDirection.value); + } + + [Test] + public void Constructor_SetsCenterAlignment() + { + Assert.AreEqual(Align.Center, _statusBar.style.alignItems.value); + } + + [Test] + public void Constructor_SetsPadding() + { + Assert.AreEqual(Tokens.Spacing.MD, _statusBar.style.paddingTop.value.value); + Assert.AreEqual(Tokens.Spacing.Lg, _statusBar.style.paddingRight.value.value); + Assert.AreEqual(Tokens.Spacing.MD, _statusBar.style.paddingBottom.value.value); + Assert.AreEqual(Tokens.Spacing.Lg, _statusBar.style.paddingLeft.value.value); + } + + [Test] + public void Constructor_SetsBorderRadius() + { + Assert.AreEqual(Tokens.BorderRadius.MD, _statusBar.style.borderTopLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _statusBar.style.borderTopRightRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _statusBar.style.borderBottomLeftRadius.value.value); + Assert.AreEqual(Tokens.BorderRadius.MD, _statusBar.style.borderBottomRightRadius.value.value); + } + + [Test] + public void Constructor_SetsBottomMargin() + { + Assert.AreEqual(Tokens.Spacing.MD, _statusBar.style.marginBottom.value.value); + } + + [Test] + public void Constructor_SetsLeftBorderWidth() + { + Assert.AreEqual(3f, _statusBar.style.borderLeftWidth.value); + } + + [Test] + public void Constructor_CreatesTextLabel() + { + Assert.IsNotNull(_statusBar.TextLabel); + } + + [Test] + public void Constructor_TextLabelHasCorrectClass() + { + Assert.IsTrue(_statusBar.TextLabel.ClassListContains("j-status-bar__text")); + } + + [Test] + public void Constructor_TextLabelHasBaseFontSize() + { + Assert.AreEqual(Tokens.FontSize.Base, _statusBar.TextLabel.style.fontSize.value.value); + } + + #endregion + + #region Text Property Tests + + [Test] + public void Text_Get_ReturnsCurrentText() + { + Assert.AreEqual("Test message", _statusBar.Text); + } + + [Test] + public void Text_Set_UpdatesText() + { + _statusBar.Text = "New message"; + Assert.AreEqual("New message", _statusBar.Text); + } + + [Test] + public void Text_Set_Empty_SetsEmptyText() + { + _statusBar.Text = ""; + Assert.AreEqual("", _statusBar.Text); + } + + #endregion + + #region Status Property Tests + + [Test] + public void Status_Get_ReturnsCurrentStatus() + { + var bar = new JStatusBar("msg", StatusType.Warning); + Assert.AreEqual(StatusType.Warning, bar.Status); + } + + [Test] + public void Status_Set_UpdatesStatus() + { + _statusBar.Status = StatusType.Error; + Assert.AreEqual(StatusType.Error, _statusBar.Status); + } + + #endregion + + #region SetStatus Tests + + [Test] + public void SetStatus_Info_AddsInfoClass() + { + _statusBar.SetStatus(StatusType.Info); + Assert.IsTrue(_statusBar.ClassListContains("j-status-bar--info")); + } + + [Test] + public void SetStatus_Success_AddsSuccessClass() + { + _statusBar.SetStatus(StatusType.Success); + Assert.IsTrue(_statusBar.ClassListContains("j-status-bar--success")); + } + + [Test] + public void SetStatus_Warning_AddsWarningClass() + { + _statusBar.SetStatus(StatusType.Warning); + Assert.IsTrue(_statusBar.ClassListContains("j-status-bar--warning")); + } + + [Test] + public void SetStatus_Error_AddsErrorClass() + { + _statusBar.SetStatus(StatusType.Error); + Assert.IsTrue(_statusBar.ClassListContains("j-status-bar--error")); + } + + [Test] + public void SetStatus_RemovesPreviousStatusClass() + { + _statusBar.SetStatus(StatusType.Info); + _statusBar.SetStatus(StatusType.Error); + + Assert.IsFalse(_statusBar.ClassListContains("j-status-bar--info")); + Assert.IsTrue(_statusBar.ClassListContains("j-status-bar--error")); + } + + [Test] + public void SetStatus_ReturnsStatusBarForChaining() + { + var result = _statusBar.SetStatus(StatusType.Success); + Assert.AreSame(_statusBar, result); + } + + [Test] + public void SetStatus_SetsSurfaceBackgroundColor() + { + _statusBar.SetStatus(StatusType.Success); + Assert.AreEqual(Tokens.Colors.BgSurface, _statusBar.style.backgroundColor.value); + } + + [Test] + public void SetStatus_SetsLeftBorderColor() + { + _statusBar.SetStatus(StatusType.Info); + Assert.AreEqual(Tokens.Colors.Border, _statusBar.style.borderLeftColor.value); + } + + #endregion + + #region WithText Tests + + [Test] + public void WithText_UpdatesText() + { + _statusBar.WithText("Updated text"); + Assert.AreEqual("Updated text", _statusBar.Text); + } + + [Test] + public void WithText_ReturnsStatusBarForChaining() + { + var result = _statusBar.WithText("test"); + Assert.AreSame(_statusBar, result); + } + + #endregion + + #region TextLabel Property Tests + + [Test] + public void TextLabel_ReturnsLabelElement() + { + Assert.IsInstanceOf