diff --git a/My project/Assets/FlowScope/Addressables.meta b/My project/Assets/FlowScope/Addressables.meta new file mode 100644 index 0000000..2fb5190 --- /dev/null +++ b/My project/Assets/FlowScope/Addressables.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e74ee61e34d742b9b44057032c6431bb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Addressables/AddressablesResourceBackend.cs b/My project/Assets/FlowScope/Addressables/AddressablesResourceBackend.cs new file mode 100644 index 0000000..d887982 --- /dev/null +++ b/My project/Assets/FlowScope/Addressables/AddressablesResourceBackend.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Resources; +using UnityEngine.AddressableAssets; + +namespace FlowScope.Addressables +{ + public sealed class AddressablesResourceBackend : IResourceBackend + { + public string Name => "Addressables"; + + public async Task LoadAsync(string key, CancellationToken cancellationToken) + where T : class + { + var handle = Addressables.LoadAssetAsync(key); + await WaitWithCancellation(handle.Task, cancellationToken); + return handle.Result; + } + + public void Release(string key, T asset) + where T : class + { + Addressables.Release(asset); + } + + private static async Task WaitWithCancellation(Task task, CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + await task; + return; + } + + var cancellation = new TaskCompletionSource(); + using (cancellationToken.Register(() => cancellation.TrySetResult(true))) + { + if (await Task.WhenAny(task, cancellation.Task) == cancellation.Task) + { + throw new OperationCanceledException(cancellationToken); + } + } + + await task; + } + } +} diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs.meta b/My project/Assets/FlowScope/Addressables/AddressablesResourceBackend.cs.meta similarity index 74% rename from My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs.meta rename to My project/Assets/FlowScope/Addressables/AddressablesResourceBackend.cs.meta index e13c995..d8f38df 100644 --- a/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs.meta +++ b/My project/Assets/FlowScope/Addressables/AddressablesResourceBackend.cs.meta @@ -1,5 +1,5 @@ -fileFormatVersion: 2 -guid: 6a8dd01aed1f4fac97325a858dc56e78 +fileFormatVersion: 2 +guid: f65b4f61d3a34358bf48957eb3a14dd7 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/My project/Assets/FlowScope/Addressables/AddressablesResourceService.cs b/My project/Assets/FlowScope/Addressables/AddressablesResourceService.cs new file mode 100644 index 0000000..d715a0c --- /dev/null +++ b/My project/Assets/FlowScope/Addressables/AddressablesResourceService.cs @@ -0,0 +1,38 @@ +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Addressables; + +namespace FlowScope.Resources +{ + public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService + { + private readonly ResourceService _inner; + + public AddressablesResourceService() + : this(new AddressablesResourceBackend()) + { + } + + public AddressablesResourceService(IResourceBackend backend) + { + _inner = new ResourceService(backend); + } + + public Task> LoadAsync(string key, CancellationToken cancellationToken) + where T : class + { + return _inner.LoadAsync(key, cancellationToken); + } + + public IResourceGroup CreateGroup() + { + return _inner.CreateGroup(); + } + + public bool TryLoadCompleted(string key, out IResourceHandle handle) + where T : class + { + return _inner.TryLoadCompleted(key, out handle); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs.meta b/My project/Assets/FlowScope/Addressables/AddressablesResourceService.cs.meta similarity index 74% rename from My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs.meta rename to My project/Assets/FlowScope/Addressables/AddressablesResourceService.cs.meta index 3a210c0..b66f952 100644 --- a/My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs.meta +++ b/My project/Assets/FlowScope/Addressables/AddressablesResourceService.cs.meta @@ -1,5 +1,5 @@ -fileFormatVersion: 2 -guid: c9294e0aadd246de906d8cfb96d81878 +fileFormatVersion: 2 +guid: 62c87792aac14414b46ac0eb3e716f25 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/My project/Assets/FlowScope/Addressables/FlowScope.Addressables.asmdef b/My project/Assets/FlowScope/Addressables/FlowScope.Addressables.asmdef new file mode 100644 index 0000000..7a5e492 --- /dev/null +++ b/My project/Assets/FlowScope/Addressables/FlowScope.Addressables.asmdef @@ -0,0 +1,17 @@ +{ + "name": "FlowScope.Addressables", + "rootNamespace": "FlowScope.Addressables", + "references": [ + "FlowScope.Runtime", + "Unity.Addressables" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/My project/Assets/FlowScope/Addressables/FlowScope.Addressables.asmdef.meta b/My project/Assets/FlowScope/Addressables/FlowScope.Addressables.asmdef.meta new file mode 100644 index 0000000..b4194a7 --- /dev/null +++ b/My project/Assets/FlowScope/Addressables/FlowScope.Addressables.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 917e69d4247b4f548a5951d84e7d35db +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Resources/ResourceEntry.cs b/My project/Assets/FlowScope/Runtime/Resources/ResourceEntry.cs index 02195cc..d3f0d77 100644 --- a/My project/Assets/FlowScope/Runtime/Resources/ResourceEntry.cs +++ b/My project/Assets/FlowScope/Runtime/Resources/ResourceEntry.cs @@ -57,5 +57,18 @@ namespace FlowScope.Resources Asset = null; _release(Key, AssetType, asset); } + + public void ReleaseWithoutHandle() + { + if (_released) + { + return; + } + + _released = true; + var asset = Asset; + Asset = null; + _release(Key, AssetType, asset); + } } } diff --git a/My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs b/My project/Assets/FlowScope/Runtime/Resources/ResourceService.cs similarity index 54% rename from My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs rename to My project/Assets/FlowScope/Runtime/Resources/ResourceService.cs index 2ba293b..39c5faf 100644 --- a/My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs +++ b/My project/Assets/FlowScope/Runtime/Resources/ResourceService.cs @@ -1,30 +1,20 @@ using System; using System.Collections.Generic; -using System.Reflection; using System.Threading; using System.Threading.Tasks; namespace FlowScope.Resources { - public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService + public sealed class ResourceService : IResourceService, ICompletedResourceService { private readonly object _gate = new(); + private readonly IResourceBackend _backend; private readonly Dictionary _entries = new(); private readonly Dictionary _loads = new(); - private readonly Func> _loadAssetAsync; - private readonly Action _releaseAsset; - public AddressablesResourceService() - : this(AddressablesReflectionBackend.LoadAsync, AddressablesReflectionBackend.Release) + public ResourceService(IResourceBackend backend) { - } - - public AddressablesResourceService( - Func> loadAssetAsync, - Action releaseAsset) - { - _loadAssetAsync = loadAssetAsync ?? throw new ArgumentNullException(nameof(loadAssetAsync)); - _releaseAsset = releaseAsset ?? throw new ArgumentNullException(nameof(releaseAsset)); + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); } public async Task> LoadAsync( @@ -81,10 +71,6 @@ namespace FlowScope.Resources throw; } - catch - { - throw; - } } public bool TryLoadCompleted(string key, out IResourceHandle handle) @@ -113,32 +99,33 @@ namespace FlowScope.Resources return new ResourceGroup(); } - private async Task LoadEntryAsync(string key, CancellationToken cancellationToken) + private async Task LoadEntryAsync(string key) where T : class { - object asset; + T asset; try { - asset = await _loadAssetAsync(key, typeof(T), cancellationToken); + asset = await _backend.LoadAsync(key, CancellationToken.None); } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) + catch (Exception exception) when (exception is not OperationCanceledException) { throw new InvalidOperationException( - $"Failed to load Addressables resource '{key}' as {typeof(T).Name}.", + $"Failed to load resource '{key}' as {typeof(T).Name} from backend '{_backend.Name}'.", exception); } if (asset == null) { throw new InvalidOperationException( - $"Addressables resource '{key}' returned null for {typeof(T).Name}."); + $"Resource '{key}' returned null for {typeof(T).Name} from backend '{_backend.Name}'."); } - return new ResourceEntry(key, typeof(T), asset, ReleaseEntry); + return new ResourceEntry( + key, + typeof(T), + asset, + (entryKey, assetType, releasedAsset) => + ReleaseBackendEntry(entryKey, assetType, (T)releasedAsset)); } private async Task LoadAndOwnEntryAsync( @@ -150,7 +137,7 @@ namespace FlowScope.Resources ResourceEntry entry = null; try { - entry = await LoadEntryAsync(key, CancellationToken.None); + entry = await LoadEntryAsync(key); lock (_gate) { _loads.Remove(resourceKey); @@ -161,7 +148,7 @@ namespace FlowScope.Resources } } - ReleaseEntry(entry.Key, entry.AssetType, entry.Asset); + ReleaseEntry(entry); return entry; } catch @@ -196,14 +183,20 @@ namespace FlowScope.Resources return await loadTask; } - private void ReleaseEntry(string key, Type assetType, object asset) + private void ReleaseEntry(ResourceEntry entry) + { + entry.ReleaseWithoutHandle(); + } + + private void ReleaseBackendEntry(string key, Type assetType, T asset) + where T : class { lock (_gate) { _entries.Remove(new ResourceKey(key, assetType)); } - _releaseAsset(key, asset); + _backend.Release(key, asset); } private sealed class SharedLoad @@ -242,83 +235,5 @@ namespace FlowScope.Resources } } } - - private static class AddressablesReflectionBackend - { - private const string AddressablesTypeName = - "UnityEngine.AddressableAssets.Addressables, Unity.Addressables"; - - public static async Task LoadAsync( - string key, - Type assetType, - CancellationToken cancellationToken) - { - var addressablesType = Type.GetType(AddressablesTypeName); - if (addressablesType == null) - { - throw new InvalidOperationException( - "Unity Addressables package is not available. P0 ResourceService supports Addressables only."); - } - - var method = FindLoadAssetMethod(addressablesType).MakeGenericMethod(assetType); - var handle = method.Invoke(null, new object[] { key }); - var task = (Task)handle.GetType().GetProperty("Task", BindingFlags.Instance | BindingFlags.Public) - ?.GetValue(handle); - if (task == null) - { - throw new InvalidOperationException("Addressables LoadAssetAsync handle did not expose Task."); - } - - await WaitWithCancellation(task, cancellationToken); - return task.GetType().GetProperty("Result")?.GetValue(task); - } - - public static void Release(string key, object asset) - { - var addressablesType = Type.GetType(AddressablesTypeName); - var release = addressablesType?.GetMethod( - "Release", - BindingFlags.Static | BindingFlags.Public, - null, - new[] { typeof(object) }, - null); - release?.Invoke(null, new[] { asset }); - } - - private static MethodInfo FindLoadAssetMethod(Type addressablesType) - { - foreach (var method in addressablesType.GetMethods(BindingFlags.Static | BindingFlags.Public)) - { - if (method.Name == "LoadAssetAsync" && - method.IsGenericMethodDefinition && - method.GetParameters().Length == 1) - { - return method; - } - } - - throw new InvalidOperationException("Addressables.LoadAssetAsync(key) was not found."); - } - - private static async Task WaitWithCancellation(Task task, CancellationToken cancellationToken) - { - if (!cancellationToken.CanBeCanceled) - { - await task; - return; - } - - var cancellation = new TaskCompletionSource(); - using (cancellationToken.Register(() => cancellation.TrySetResult(true))) - { - if (await Task.WhenAny(task, cancellation.Task) == cancellation.Task) - { - throw new OperationCanceledException(cancellationToken); - } - } - - await task; - } - } } } diff --git a/My project/Assets/FlowScope/Runtime/Resources/ResourceService.cs.meta b/My project/Assets/FlowScope/Runtime/Resources/ResourceService.cs.meta new file mode 100644 index 0000000..622cec2 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Resources/ResourceService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d123a5c6220843b18a09611f77e8cd31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Resources/ResourceServiceBackendTests.cs b/My project/Assets/FlowScope/Tests/EditMode/Resources/ResourceServiceBackendTests.cs new file mode 100644 index 0000000..c41a920 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Resources/ResourceServiceBackendTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Resources; +using NUnit.Framework; + +namespace FlowScope.Tests.EditMode.Resources +{ + public sealed class ResourceServiceBackendTests + { + [Test] + public async Task LoadAsync_WhenSameKeyLoadsConcurrently_UsesOneBackendLoad() + { + var backend = new FakeResourceBackend(); + var service = new ResourceService(backend); + + var first = service.LoadAsync("shared", CancellationToken.None); + var second = service.LoadAsync("shared", CancellationToken.None); + backend.Gate.SetResult(new TestAsset()); + + using var firstHandle = await first; + using var secondHandle = await second; + + Assert.AreEqual(1, backend.LoadCalls); + Assert.AreSame(firstHandle.Asset, secondHandle.Asset); + } + + [Test] + public async Task LoadAsync_WhenCanceled_DoesNotCreateHandle() + { + var backend = new FakeResourceBackend(); + var service = new ResourceService(backend); + using var cts = new CancellationTokenSource(); + + var task = service.LoadAsync("canceled", cts.Token); + cts.Cancel(); + + await ExpectThrowsAsync(task); + Assert.IsFalse(service.TryLoadCompleted("canceled", out var handle)); + Assert.IsNull(handle); + } + + [Test] + public async Task DisposeLastHandle_ReleasesBackendAsset() + { + var backend = new FakeResourceBackend(); + var service = new ResourceService(backend); + + var first = service.LoadAsync("shared", CancellationToken.None); + var second = service.LoadAsync("shared", CancellationToken.None); + backend.Gate.SetResult(new TestAsset()); + var firstHandle = await first; + var secondHandle = await second; + + firstHandle.Dispose(); + Assert.AreEqual(0, backend.ReleaseCalls); + + secondHandle.Dispose(); + Assert.AreEqual(1, backend.ReleaseCalls); + } + + [Test] + public async Task DisposeLastHandle_RemovesCompletedCacheEntry() + { + var backend = new FakeResourceBackend(); + var service = new ResourceService(backend); + + var load = service.LoadAsync("cached", CancellationToken.None); + backend.Gate.SetResult(new TestAsset()); + var handle = await load; + + Assert.IsTrue(service.TryLoadCompleted("cached", out var cached)); + cached.Dispose(); + handle.Dispose(); + + Assert.IsFalse(service.TryLoadCompleted("cached", out _)); + } + + [Test] + public async Task CreateGroup_DisposesAllHandles() + { + var backend = new FakeResourceBackend(); + var service = new ResourceService(backend); + var group = service.CreateGroup(); + + var first = service.LoadAsync("first", CancellationToken.None); + backend.Gate.SetResult(new TestAsset()); + var firstHandle = await first; + group.Add(firstHandle); + + backend.Gate = new TaskCompletionSource(); + var second = service.LoadAsync("second", CancellationToken.None); + backend.Gate.SetResult(new TestAsset()); + var secondHandle = await second; + group.Add(secondHandle); + + group.Dispose(); + + Assert.IsTrue(firstHandle.IsDisposed); + Assert.IsTrue(secondHandle.IsDisposed); + Assert.AreEqual(2, backend.ReleaseCalls); + } + + private sealed class FakeResourceBackend : IResourceBackend + { + public string Name => "Fake"; + public int LoadCalls { get; private set; } + public int ReleaseCalls { get; private set; } + public TaskCompletionSource Gate { get; set; } = new(); + + public async Task LoadAsync(string key, CancellationToken cancellationToken) + where T : class + { + LoadCalls++; + return (T)await Gate.Task; + } + + public void Release(string key, T asset) + where T : class + { + ReleaseCalls++; + } + } + + private sealed class TestAsset + { + } + + private static async Task ExpectThrowsAsync(Task task) + where TException : Exception + { + try + { + await task; + } + catch (TException) + { + return; + } + + Assert.Fail($"Expected {typeof(TException).Name}."); + } + } +} diff --git a/My project/Assets/FlowScope/Tests/EditMode/Resources/ResourceServiceBackendTests.cs.meta b/My project/Assets/FlowScope/Tests/EditMode/Resources/ResourceServiceBackendTests.cs.meta new file mode 100644 index 0000000..e10138d --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Resources/ResourceServiceBackendTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb5a9158d4884293847009acc433b248 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/FlowScope.Tests.PlayMode.asmdef b/My project/Assets/FlowScope/Tests/PlayMode/FlowScope.Tests.PlayMode.asmdef index 0da6381..4645407 100644 --- a/My project/Assets/FlowScope/Tests/PlayMode/FlowScope.Tests.PlayMode.asmdef +++ b/My project/Assets/FlowScope/Tests/PlayMode/FlowScope.Tests.PlayMode.asmdef @@ -2,6 +2,7 @@ "name": "FlowScope.Tests.PlayMode", "rootNamespace": "FlowScope.Tests", "references": [ + "FlowScope.Addressables", "FlowScope.Runtime", "FlowScope.Samples.MainMenuP0", "R3.Unity" diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceBackendTests.cs b/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceBackendTests.cs new file mode 100644 index 0000000..d90986f --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceBackendTests.cs @@ -0,0 +1,16 @@ +using FlowScope.Addressables; +using NUnit.Framework; + +namespace FlowScope.Tests.PlayMode.Resources +{ + public sealed class AddressablesResourceBackendTests + { + [Test] + public void Name_ReturnsAddressables() + { + var backend = new AddressablesResourceBackend(); + + Assert.AreEqual("Addressables", backend.Name); + } + } +} diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceBackendTests.cs.meta b/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceBackendTests.cs.meta new file mode 100644 index 0000000..52a4948 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceBackendTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 596056ac39b54c0cb812046585dddf65 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs b/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs deleted file mode 100644 index 7acb1c3..0000000 --- a/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs +++ /dev/null @@ -1,218 +0,0 @@ -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 - { - } - } -} diff --git a/My project/Packages/manifest.json b/My project/Packages/manifest.json index bf86cd9..d220fe7 100644 --- a/My project/Packages/manifest.json +++ b/My project/Packages/manifest.json @@ -12,6 +12,7 @@ "com.cysharp.r3": "https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity#1.3.0", "com.unity.collab-proxy": "2.12.4", "com.unity.feature.development": "1.0.1", + "com.unity.addressables": "1.21.21", "com.unity.textmeshpro": "3.0.7", "com.unity.timeline": "1.7.7", "com.unity.ugui": "1.0.0",