using System; using System.Collections; using System.Threading; using System.Threading.Tasks; using FlowScope.Resources; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; namespace FlowScope.Tests.PlayMode.Resources { public sealed class AddressablesResourceServiceTests { [UnityTest] public IEnumerator LoadAsync_WhenSameKeyLoadsConcurrently_SharesOneBackendLoad() { var backendLoads = 0; var releaseCount = 0; var gate = new TaskCompletionSource(); var service = new AddressablesResourceService( async (_, _, _) => { backendLoads++; return await gate.Task; }, (_, _) => releaseCount++); var first = service.LoadAsync("shared", CancellationToken.None); var second = service.LoadAsync("shared", CancellationToken.None); gate.SetResult(new TestAsset()); IResourceHandle firstHandle = null; IResourceHandle secondHandle = null; yield return AwaitTask(first, handle => firstHandle = handle); yield return AwaitTask(second, handle => secondHandle = handle); Assert.AreEqual(1, backendLoads); Assert.AreSame(firstHandle.Asset, secondHandle.Asset); firstHandle.Dispose(); Assert.AreEqual(0, releaseCount); secondHandle.Dispose(); Assert.AreEqual(1, releaseCount); } [UnityTest] public IEnumerator LoadAsync_WhenBackendFails_IncludesKeyAndBackend() { var service = new AddressablesResourceService( (_, _, _) => throw new InvalidOperationException("missing"), (_, _) => { }); InvalidOperationException exception = null; yield return ThrowsAsync( () => service.LoadAsync("missing-key", CancellationToken.None), caught => exception = caught); StringAssert.Contains("missing-key", exception.Message); StringAssert.Contains("Addressables", exception.Message); } [UnityTest] public IEnumerator LoadAsync_WhenOneConcurrentCallerCancels_OtherCallerStillReceivesSharedLoad() { var backendLoads = 0; var gate = new TaskCompletionSource(); var service = new AddressablesResourceService( async (_, _, cancellationToken) => { backendLoads++; Assert.That(cancellationToken.CanBeCanceled, Is.False); return await gate.Task; }, (_, _) => { }); using var cts = new CancellationTokenSource(); var canceled = service.LoadAsync("shared", cts.Token); var survivor = service.LoadAsync("shared", CancellationToken.None); cts.Cancel(); yield return ThrowsAsync(canceled); gate.SetResult(new TestAsset()); IResourceHandle survivorHandle = null; yield return AwaitTask(survivor, handle => survivorHandle = handle); Assert.AreEqual(1, backendLoads); Assert.IsNotNull(survivorHandle.Asset); survivorHandle.Dispose(); } [UnityTest] public IEnumerator LoadAsync_WhenOnlyCallerCancels_ReleasesCompletedBackendAsset() { var backendLoads = 0; var releaseCount = 0; var gate = new TaskCompletionSource(); var service = new AddressablesResourceService( async (_, _, cancellationToken) => { backendLoads++; Assert.That(cancellationToken.CanBeCanceled, Is.False); if (backendLoads == 1) { return await gate.Task; } return new TestAsset(); }, (_, _) => releaseCount++); using var cts = new CancellationTokenSource(); var canceled = service.LoadAsync("orphan", cts.Token); cts.Cancel(); yield return ThrowsAsync(canceled); gate.SetResult(new TestAsset()); yield return new WaitUntil(() => releaseCount == 1); Assert.IsFalse(service.TryLoadCompleted("orphan", out _)); IResourceHandle nextHandle = null; yield return AwaitTask( service.LoadAsync("orphan", CancellationToken.None), handle => nextHandle = handle); Assert.AreEqual(2, backendLoads); Assert.IsNotNull(nextHandle.Asset); nextHandle.Dispose(); Assert.AreEqual(2, releaseCount); } [UnityTest] public IEnumerator TryLoadCompleted_AfterAsyncLoad_ReturnsCachedHandleWithoutBackendLoad() { var backendLoads = 0; var service = new AddressablesResourceService( (_, _, _) => { backendLoads++; return Task.FromResult(new TestAsset()); }, (_, _) => { }); IResourceHandle first = null; yield return AwaitTask( service.LoadAsync("cached", CancellationToken.None), handle => first = handle); Assert.IsTrue(service.TryLoadCompleted("cached", out var second)); Assert.AreEqual(1, backendLoads); Assert.AreSame(first.Asset, second.Asset); first.Dispose(); second.Dispose(); } private static IEnumerator ThrowsAsync(Func action, Action onCaught) where TException : Exception { var task = action(); while (!task.IsCompleted) { yield return null; } if (task.IsFaulted && task.Exception?.GetBaseException() is TException exception) { onCaught(exception); yield break; } Assert.Fail($"Expected {typeof(TException).Name}."); } private static IEnumerator ThrowsAsync(Task task) where TException : Exception { while (!task.IsCompleted) { yield return null; } if (task.IsFaulted && task.Exception?.GetBaseException() is TException) { yield break; } if (task.IsCanceled && typeof(TException) == typeof(OperationCanceledException)) { yield break; } Assert.Fail($"Expected {typeof(TException).Name}."); } private static IEnumerator AwaitTask(Task task, Action onCompleted) { while (!task.IsCompleted) { yield return null; } if (task.IsFaulted) { throw task.Exception.GetBaseException(); } if (task.IsCanceled) { throw new OperationCanceledException(); } onCompleted(task.Result); } private sealed class TestAsset { } } }