完成 P2 前架构整改
This commit is contained in:
@@ -37,6 +37,11 @@ namespace FlowScope.Container
|
||||
}
|
||||
|
||||
public void RegisterFactory<T>(Func<Container, T> factory)
|
||||
{
|
||||
RegisterSingletonFactory(factory);
|
||||
}
|
||||
|
||||
public void RegisterSingletonFactory<T>(Func<Container, T> factory)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
@@ -45,7 +50,31 @@ namespace FlowScope.Container
|
||||
throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
RegisterLocal(typeof(T), ContainerRegistration.ForFactory(this, c => factory(c)));
|
||||
RegisterLocal(typeof(T), ContainerRegistration.ForSingletonFactory(this, c => factory(c)));
|
||||
}
|
||||
|
||||
public void RegisterScoped<T>(Func<Container, T> factory)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (factory == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
RegisterLocal(typeof(T), ContainerRegistration.ForScopedFactory(this, c => factory(c)));
|
||||
}
|
||||
|
||||
public void RegisterTransient<T>(Func<Container, T> factory)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (factory == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
RegisterLocal(typeof(T), ContainerRegistration.ForTransientFactory(this, c => factory(c)));
|
||||
}
|
||||
|
||||
public void RegisterType<TInterface, TImplementation>() where TImplementation : TInterface
|
||||
@@ -121,6 +150,7 @@ namespace FlowScope.Container
|
||||
|
||||
_children.Clear();
|
||||
_ownedDisposables.Clear();
|
||||
ForgetScopedInstancesFor(this);
|
||||
_registrations.Clear();
|
||||
|
||||
if (_parent != null)
|
||||
@@ -160,7 +190,7 @@ namespace FlowScope.Container
|
||||
throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
RegisterLocal(serviceType, ContainerRegistration.ForFactory(this, factory));
|
||||
RegisterLocal(serviceType, ContainerRegistration.ForTransientFactory(this, factory));
|
||||
}
|
||||
|
||||
internal void TrackOwnedDisposable(IDisposable disposable)
|
||||
@@ -209,7 +239,7 @@ namespace FlowScope.Container
|
||||
_resolutionStack.Push(serviceType);
|
||||
try
|
||||
{
|
||||
return registration.Resolve();
|
||||
return registration.Resolve(this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -236,6 +266,16 @@ namespace FlowScope.Container
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ForgetScopedInstancesFor(Container scope)
|
||||
{
|
||||
foreach (var registration in _registrations.Values)
|
||||
{
|
||||
registration.ForgetScope(scope);
|
||||
}
|
||||
|
||||
_parent?.ForgetScopedInstancesFor(scope);
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
|
||||
@@ -1,50 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FlowScope.Container
|
||||
{
|
||||
internal sealed class ContainerRegistration
|
||||
{
|
||||
private enum Lifetime
|
||||
{
|
||||
Instance,
|
||||
Singleton,
|
||||
Scoped,
|
||||
Transient
|
||||
}
|
||||
|
||||
private readonly Container _owner;
|
||||
private readonly Func<Container, object> _factory;
|
||||
private readonly bool _ownsCreatedInstance;
|
||||
private readonly Lifetime _lifetime;
|
||||
private readonly Dictionary<Container, object> _scopedInstances;
|
||||
private object _instance;
|
||||
private bool _created;
|
||||
|
||||
private ContainerRegistration(Container owner, Func<Container, object> factory, object instance, bool created, bool ownsCreatedInstance)
|
||||
private ContainerRegistration(
|
||||
Container owner,
|
||||
Func<Container, object> factory,
|
||||
object instance,
|
||||
bool created,
|
||||
Lifetime lifetime)
|
||||
{
|
||||
_owner = owner;
|
||||
_factory = factory;
|
||||
_instance = instance;
|
||||
_created = created;
|
||||
_ownsCreatedInstance = ownsCreatedInstance;
|
||||
_lifetime = lifetime;
|
||||
_scopedInstances = lifetime == Lifetime.Scoped
|
||||
? new Dictionary<Container, object>()
|
||||
: null;
|
||||
}
|
||||
|
||||
public static ContainerRegistration ForInstance(object instance)
|
||||
{
|
||||
return new ContainerRegistration(null, null, instance, true, false);
|
||||
return new ContainerRegistration(null, null, instance, true, Lifetime.Instance);
|
||||
}
|
||||
|
||||
public static ContainerRegistration ForFactory(Container owner, Func<Container, object> factory)
|
||||
public static ContainerRegistration ForSingletonFactory(Container owner, Func<Container, object> factory)
|
||||
{
|
||||
return new ContainerRegistration(owner, factory, null, false, true);
|
||||
return new ContainerRegistration(owner, factory, null, false, Lifetime.Singleton);
|
||||
}
|
||||
|
||||
public object Resolve()
|
||||
public static ContainerRegistration ForScopedFactory(Container owner, Func<Container, object> factory)
|
||||
{
|
||||
if (_created)
|
||||
return new ContainerRegistration(owner, factory, null, false, Lifetime.Scoped);
|
||||
}
|
||||
|
||||
public static ContainerRegistration ForTransientFactory(Container owner, Func<Container, object> factory)
|
||||
{
|
||||
return new ContainerRegistration(owner, factory, null, false, Lifetime.Transient);
|
||||
}
|
||||
|
||||
public object Resolve(Container requestScope)
|
||||
{
|
||||
switch (_lifetime)
|
||||
{
|
||||
return _instance;
|
||||
case Lifetime.Instance:
|
||||
return _instance;
|
||||
case Lifetime.Singleton:
|
||||
return ResolveSingleton();
|
||||
case Lifetime.Scoped:
|
||||
return ResolveScoped(requestScope);
|
||||
case Lifetime.Transient:
|
||||
return CreateOwnedInstance(requestScope);
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported container lifetime {_lifetime}.");
|
||||
}
|
||||
}
|
||||
|
||||
_instance = _factory(_owner);
|
||||
_created = true;
|
||||
public void ForgetScope(Container scope)
|
||||
{
|
||||
_scopedInstances?.Remove(scope);
|
||||
}
|
||||
|
||||
if (_ownsCreatedInstance && _instance is IDisposable disposable)
|
||||
private object ResolveSingleton()
|
||||
{
|
||||
if (!_created)
|
||||
{
|
||||
_owner.TrackOwnedDisposable(disposable);
|
||||
_instance = CreateOwnedInstance(_owner);
|
||||
_created = true;
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
|
||||
private object ResolveScoped(Container requestScope)
|
||||
{
|
||||
if (_scopedInstances.TryGetValue(requestScope, out var instance))
|
||||
{
|
||||
return instance;
|
||||
}
|
||||
|
||||
instance = CreateOwnedInstance(requestScope);
|
||||
_scopedInstances.Add(requestScope, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private object CreateOwnedInstance(Container owner)
|
||||
{
|
||||
var instance = _factory(owner);
|
||||
|
||||
if (instance is IDisposable disposable)
|
||||
{
|
||||
owner.TrackOwnedDisposable(disposable);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ namespace FlowScope.Flow
|
||||
}
|
||||
catch
|
||||
{
|
||||
BestEffortDisposeFeature(feature);
|
||||
BestEffortDisposeContext(context);
|
||||
_activeFeature = null;
|
||||
_activeContext = null;
|
||||
@@ -79,7 +78,6 @@ namespace FlowScope.Flow
|
||||
}
|
||||
catch
|
||||
{
|
||||
BestEffortDisposeFeature(nextFeature);
|
||||
BestEffortDisposeContext(nextContext);
|
||||
_activeFeature = null;
|
||||
_activeContext = null;
|
||||
@@ -170,30 +168,11 @@ namespace FlowScope.Flow
|
||||
{
|
||||
Debug.LogError(exception);
|
||||
}
|
||||
|
||||
BestEffortDisposeFeature(feature);
|
||||
}
|
||||
|
||||
BestEffortDisposeContext(context);
|
||||
}
|
||||
|
||||
private void BestEffortDisposeFeature(IFeature feature)
|
||||
{
|
||||
if (feature == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
feature.Dispose();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.LogError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void BestEffortDisposeContext(FeatureContext context)
|
||||
{
|
||||
if (context == null)
|
||||
@@ -212,7 +191,7 @@ namespace FlowScope.Flow
|
||||
|
||||
try
|
||||
{
|
||||
context.Resources?.Dispose();
|
||||
context.Scope?.Dispose();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -221,7 +200,7 @@ namespace FlowScope.Flow
|
||||
|
||||
try
|
||||
{
|
||||
context.Scope?.Dispose();
|
||||
context.Resources?.Dispose();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace FlowScope.UI
|
||||
{
|
||||
private readonly IResourceService _resources;
|
||||
private readonly Dictionary<Type, IResourceHandle<GameObject>> _handles = new();
|
||||
private readonly Dictionary<Type, Task> _inFlightLoads = new();
|
||||
|
||||
public UIPreloadService(IResourceService resources)
|
||||
{
|
||||
@@ -27,16 +28,24 @@ namespace FlowScope.UI
|
||||
return;
|
||||
}
|
||||
|
||||
var attribute = UIManager.GetPanelAttribute(panelType);
|
||||
var path = UIManager.ResolvePath(panelType, attribute);
|
||||
var handle = await _resources.LoadAsync<GameObject>(path, cancellationToken);
|
||||
if (handle == null || handle.Asset == null)
|
||||
if (_inFlightLoads.TryGetValue(panelType, out var inFlightLoad))
|
||||
{
|
||||
handle?.Dispose();
|
||||
throw new InvalidOperationException($"UI panel resource is missing: {path}");
|
||||
await inFlightLoad;
|
||||
return;
|
||||
}
|
||||
|
||||
_handles[panelType] = handle;
|
||||
var attribute = UIManager.GetPanelAttribute(panelType);
|
||||
var path = UIManager.ResolvePath(panelType, attribute);
|
||||
var loadTask = LoadAndStoreAsync(panelType, path, cancellationToken);
|
||||
_inFlightLoads.Add(panelType, loadTask);
|
||||
try
|
||||
{
|
||||
await loadTask;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inFlightLoads.Remove(panelType);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPreloaded<TPanel>()
|
||||
@@ -85,5 +94,20 @@ namespace FlowScope.UI
|
||||
{
|
||||
ReleaseAll();
|
||||
}
|
||||
|
||||
private async Task LoadAndStoreAsync(
|
||||
Type panelType,
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var handle = await _resources.LoadAsync<GameObject>(path, cancellationToken);
|
||||
if (handle == null || handle.Asset == null)
|
||||
{
|
||||
handle?.Dispose();
|
||||
throw new InvalidOperationException($"UI panel resource is missing: {path}");
|
||||
}
|
||||
|
||||
_handles[panelType] = handle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,17 @@ namespace FlowScope.Samples.MainMenuP0
|
||||
private PlayerData _playerData;
|
||||
private CancellationTokenSource _lifetime;
|
||||
private bool _started;
|
||||
private bool _shutdownStarted;
|
||||
private bool _shutdown;
|
||||
|
||||
public int ReleasedResourceCount => _sampleBackend?.ReleaseCount ?? 0;
|
||||
public bool IsShutdown => _shutdown;
|
||||
public Task StartupTask { get; private set; }
|
||||
|
||||
private async void Awake()
|
||||
private void Awake()
|
||||
{
|
||||
_lifetime = new CancellationTokenSource();
|
||||
await StartupAsync(_lifetime.Token);
|
||||
StartupTask = RunStartupWithLoggingAsync(_lifetime.Token);
|
||||
}
|
||||
|
||||
public async Task StartupAsync(CancellationToken cancellationToken)
|
||||
@@ -97,13 +99,20 @@ namespace FlowScope.Samples.MainMenuP0
|
||||
_root.RegisterInstance<IUIPreloadService>(_preloadService);
|
||||
_root.RegisterInstance<IUIScreenNavigator>(screenNavigator);
|
||||
_root.RegisterInstance(_playerData);
|
||||
_root.RegisterFactory(_ => new MainMenuFeature(screenNavigator, _playerData));
|
||||
_root.RegisterTransient(_ => new MainMenuFeature(screenNavigator, _playerData));
|
||||
|
||||
await _gameFlow.StartupAsync<MainMenuFeature>(_root, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_shutdownStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_shutdownStarted = true;
|
||||
|
||||
if (_saveService != null && _playerData != null)
|
||||
{
|
||||
await _saveService.SaveAsync(SaveKey, _playerData, cancellationToken);
|
||||
@@ -118,10 +127,10 @@ namespace FlowScope.Samples.MainMenuP0
|
||||
_shutdown = true;
|
||||
}
|
||||
|
||||
private async void OnApplicationQuit()
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
_lifetime?.Cancel();
|
||||
await ShutdownAsync(CancellationToken.None);
|
||||
_ = RunShutdownWithLoggingAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
@@ -130,6 +139,31 @@ namespace FlowScope.Samples.MainMenuP0
|
||||
_lifetime?.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunStartupWithLoggingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await StartupAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.LogException(exception);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunShutdownWithLoggingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ShutdownAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.LogException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Canvas ResolveHudCanvas()
|
||||
{
|
||||
if (hudCanvas != null)
|
||||
|
||||
@@ -44,6 +44,71 @@ namespace FlowScope.Tests.EditMode.Container
|
||||
CollectionAssert.AreEqual(new[] { "second", "first" }, disposeOrder);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterSingletonFactory_Resolve_CreatesOnceAndIsOwnedByRegistrationScope()
|
||||
{
|
||||
var disposeOrder = new List<string>();
|
||||
var parent = new FlowScope.Container.Container();
|
||||
parent.RegisterSingletonFactory<IService>(_ => new DisposableNamedService("singleton", disposeOrder));
|
||||
var child = parent.CreateScope();
|
||||
|
||||
var first = child.Resolve<IService>();
|
||||
var second = child.Resolve<IService>();
|
||||
|
||||
Assert.AreSame(first, second);
|
||||
|
||||
child.Dispose();
|
||||
|
||||
CollectionAssert.IsEmpty(disposeOrder);
|
||||
|
||||
parent.Dispose();
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "singleton" }, disposeOrder);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterTransient_Resolve_CreatesNewInstanceOwnedByRequestScope()
|
||||
{
|
||||
var disposeOrder = new List<string>();
|
||||
var parent = new FlowScope.Container.Container();
|
||||
parent.RegisterTransient<IService>(_ => new DisposableNamedService("transient", disposeOrder));
|
||||
var child = parent.CreateScope();
|
||||
|
||||
var first = child.Resolve<IService>();
|
||||
var second = child.Resolve<IService>();
|
||||
|
||||
Assert.AreNotSame(first, second);
|
||||
|
||||
child.Dispose();
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "transient", "transient" }, disposeOrder);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterScoped_Resolve_CreatesOneInstancePerRequestScope()
|
||||
{
|
||||
var disposeOrder = new List<string>();
|
||||
var parent = new FlowScope.Container.Container();
|
||||
parent.RegisterScoped<IService>(_ => new DisposableNamedService("scoped", disposeOrder));
|
||||
var firstChild = parent.CreateScope();
|
||||
var secondChild = parent.CreateScope();
|
||||
|
||||
var firstA = firstChild.Resolve<IService>();
|
||||
var firstB = firstChild.Resolve<IService>();
|
||||
var second = secondChild.Resolve<IService>();
|
||||
|
||||
Assert.AreSame(firstA, firstB);
|
||||
Assert.AreNotSame(firstA, second);
|
||||
|
||||
firstChild.Dispose();
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "scoped" }, disposeOrder);
|
||||
|
||||
secondChild.Dispose();
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "scoped", "scoped" }, disposeOrder);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateScope_Resolve_ReadsParentAndCanOverrideWithoutPollutingParent()
|
||||
{
|
||||
@@ -152,6 +217,9 @@ namespace FlowScope.Tests.EditMode.Container
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(() => container.RegisterInstance<IService>(new NamedService("late")));
|
||||
Assert.Throws<ObjectDisposedException>(() => container.RegisterFactory<IService>(_ => new NamedService("late")));
|
||||
Assert.Throws<ObjectDisposedException>(() => container.RegisterSingletonFactory<IService>(_ => new NamedService("late")));
|
||||
Assert.Throws<ObjectDisposedException>(() => container.RegisterTransient<IService>(_ => new NamedService("late")));
|
||||
Assert.Throws<ObjectDisposedException>(() => container.RegisterScoped<IService>(_ => new NamedService("late")));
|
||||
Assert.Throws<ObjectDisposedException>(() => container.Resolve<IService>());
|
||||
Assert.Throws<ObjectDisposedException>(() => container.CreateScope());
|
||||
}
|
||||
@@ -174,6 +242,24 @@ namespace FlowScope.Tests.EditMode.Container
|
||||
public void Dispose() => IsDisposed = true;
|
||||
}
|
||||
|
||||
private sealed class DisposableNamedService : IService, IDisposable
|
||||
{
|
||||
private readonly List<string> _disposeOrder;
|
||||
|
||||
public DisposableNamedService(string name, List<string> disposeOrder)
|
||||
{
|
||||
Name = name;
|
||||
_disposeOrder = disposeOrder;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposeOrder.Add(Name);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FirstDisposable : IDisposable
|
||||
{
|
||||
private readonly List<string> _disposeOrder;
|
||||
|
||||
@@ -61,6 +61,25 @@ namespace FlowScope.Tests.EditMode.Flow
|
||||
Assert.That(SecondFeature.Instance.Context.Resources, Is.SameAs(services.Resource.Groups[1]));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SwitchToAsync_WhenReturningToFeatureType_CreatesNewFeatureInstance()
|
||||
{
|
||||
var services = new TestServices();
|
||||
var root = services.CreateRoot();
|
||||
var flow = new GameFlow();
|
||||
flow.StartupAsync<FirstFeature>(root, CancellationToken.None).GetAwaiter().GetResult();
|
||||
var firstEntry = FirstFeature.Instance;
|
||||
|
||||
flow.SwitchToAsync<SecondFeature>(CancellationToken.None).GetAwaiter().GetResult();
|
||||
flow.SwitchToAsync<FirstFeature>(CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(FirstFeature.Instance, Is.Not.SameAs(firstEntry));
|
||||
Assert.That(firstEntry.Calls, Is.EqualTo(new[] { "Load", "Enter", "Exit", "Dispose" }));
|
||||
Assert.That(FirstFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter" }));
|
||||
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
|
||||
Assert.That(services.Resource.Groups[2].IsDisposed, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StartupAsync_WhenFeatureLoadFails_CleansCreatedFeatureAndReturnsIdle()
|
||||
{
|
||||
@@ -114,7 +133,6 @@ namespace FlowScope.Tests.EditMode.Flow
|
||||
flow.StartupAsync<ThrowingExitAndDisposeFeature>(root, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
LogAssert.Expect(LogType.Error, new Regex("System\\.InvalidOperationException: exit failed"));
|
||||
LogAssert.Expect(LogType.Error, new Regex("System\\.InvalidOperationException: dispose failed"));
|
||||
LogAssert.Expect(LogType.Error, new Regex("System\\.AggregateException: One or more container-owned instances failed to dispose\\. \\(dispose failed\\)"));
|
||||
flow.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
@@ -168,11 +186,11 @@ namespace FlowScope.Tests.EditMode.Flow
|
||||
root.RegisterInstance<IConfigProvider>(Config);
|
||||
root.RegisterInstance<ISaveService>(Save);
|
||||
root.RegisterInstance<IResourceService>(Resource);
|
||||
root.RegisterFactory(_ => new FirstFeature());
|
||||
root.RegisterFactory(_ => new SecondFeature());
|
||||
root.RegisterFactory(_ => new FailingLoadFeature());
|
||||
root.RegisterFactory(_ => new FailingEnterFeature());
|
||||
root.RegisterFactory(_ => new ThrowingExitAndDisposeFeature());
|
||||
root.RegisterTransient(_ => new FirstFeature());
|
||||
root.RegisterTransient(_ => new SecondFeature());
|
||||
root.RegisterTransient(_ => new FailingLoadFeature());
|
||||
root.RegisterTransient(_ => new FailingEnterFeature());
|
||||
root.RegisterTransient(_ => new ThrowingExitAndDisposeFeature());
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,50 @@ namespace FlowScope.Tests.EditMode.Resources
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LoadAsync_WhenOneSharedWaiterCancels_OtherWaiterReceivesHandle()
|
||||
{
|
||||
var backend = new FakeResourceBackend();
|
||||
var service = new ResourceService(backend);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var canceled = service.LoadAsync<TestAsset>("shared", cts.Token);
|
||||
var retained = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
|
||||
cts.Cancel();
|
||||
backend.Gate.SetResult(new TestAsset());
|
||||
|
||||
yield return AwaitExpected<OperationCanceledException>(canceled);
|
||||
yield return Await(retained);
|
||||
|
||||
using var handle = Result(retained);
|
||||
Assert.AreEqual(1, backend.LoadCalls);
|
||||
Assert.IsNotNull(handle.Asset);
|
||||
Assert.IsTrue(service.TryLoadCompleted<TestAsset>("shared", out var cached));
|
||||
cached.Dispose();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LoadAsync_WhenAllSharedWaitersCancel_ReleasesCompletedBackendAssetWithoutCaching()
|
||||
{
|
||||
var backend = new FakeResourceBackend();
|
||||
var service = new ResourceService(backend);
|
||||
using var firstCts = new CancellationTokenSource();
|
||||
using var secondCts = new CancellationTokenSource();
|
||||
|
||||
var first = service.LoadAsync<TestAsset>("shared", firstCts.Token);
|
||||
var second = service.LoadAsync<TestAsset>("shared", secondCts.Token);
|
||||
firstCts.Cancel();
|
||||
secondCts.Cancel();
|
||||
backend.Gate.SetResult(new TestAsset());
|
||||
|
||||
yield return AwaitExpected<OperationCanceledException>(first);
|
||||
yield return AwaitExpected<OperationCanceledException>(second);
|
||||
|
||||
Assert.AreEqual(1, backend.LoadCalls);
|
||||
Assert.AreEqual(1, backend.ReleaseCalls);
|
||||
Assert.IsFalse(service.TryLoadCompleted<TestAsset>("shared", out _));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DisposeLastHandle_ReleasesBackendAsset()
|
||||
{
|
||||
|
||||
@@ -49,6 +49,26 @@ namespace FlowScope.Tests.PlayMode.Samples
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Awake_StartsThroughObservableStartupTask()
|
||||
{
|
||||
DeleteSampleSave();
|
||||
var root = new GameObject("bootstrap-awake-test");
|
||||
var bootstrap = root.AddComponent<GameBootstrap>();
|
||||
|
||||
Assert.That(bootstrap.StartupTask, Is.Not.Null);
|
||||
yield return AwaitTask(bootstrap.StartupTask);
|
||||
|
||||
var panel = Object.FindObjectOfType<MainMenuPanel>(true);
|
||||
Assert.That(panel, Is.Not.Null);
|
||||
Assert.That(panel.gameObject.activeSelf, Is.True);
|
||||
|
||||
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
|
||||
Object.DestroyImmediate(root);
|
||||
DeleteSampleSave();
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ClickIncrement_UpdatesGoldTextImmediately()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FlowScope.UI;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
@@ -58,6 +59,27 @@ namespace FlowScope.Tests.PlayMode.UI
|
||||
Assert.That(resources.Handles, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PreloadAsync_WhenSamePanelLoadsConcurrently_UsesOneResourceLoad()
|
||||
{
|
||||
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
|
||||
var resources = new GatedUITestResources("Custom/Explicit", prefab);
|
||||
var preload = new UIPreloadService(resources);
|
||||
|
||||
var first = preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None);
|
||||
var second = preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None);
|
||||
|
||||
Assert.That(resources.LoadCalls, Is.EqualTo(1));
|
||||
|
||||
resources.Complete();
|
||||
yield return UITestAsync.Await(first);
|
||||
yield return UITestAsync.Await(second);
|
||||
|
||||
Assert.That(resources.LoadCalls, Is.EqualTo(1));
|
||||
Assert.That(resources.Handle.IsDisposed, Is.False);
|
||||
Assert.That(preload.IsPreloaded<ExplicitPathPanel>(), Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator OpenAsync_WhenPanelIsPreloaded_ReusesPreloadedHandle()
|
||||
{
|
||||
@@ -111,5 +133,52 @@ namespace FlowScope.Tests.PlayMode.UI
|
||||
gameObject.AddComponent<TPanel>();
|
||||
return gameObject;
|
||||
}
|
||||
|
||||
private sealed class GatedUITestResources : FlowScope.Resources.IResourceService
|
||||
{
|
||||
private readonly string _key;
|
||||
private readonly GameObject _asset;
|
||||
private readonly TaskCompletionSource<FlowScope.Resources.IResourceHandle<GameObject>> _gate =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public GatedUITestResources(string key, GameObject asset)
|
||||
{
|
||||
_key = key;
|
||||
_asset = asset;
|
||||
Handle = new UITestResourceHandle<GameObject>(key, asset);
|
||||
}
|
||||
|
||||
public int LoadCalls { get; private set; }
|
||||
public UITestResourceHandle<GameObject> Handle { get; }
|
||||
|
||||
public Task<FlowScope.Resources.IResourceHandle<T>> LoadAsync<T>(
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
LoadCalls++;
|
||||
Assert.That(key, Is.EqualTo(_key));
|
||||
return AwaitHandle<T>();
|
||||
}
|
||||
|
||||
public FlowScope.Resources.IResourceGroup CreateGroup()
|
||||
{
|
||||
throw new System.NotSupportedException();
|
||||
}
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
_gate.SetResult(Handle);
|
||||
}
|
||||
|
||||
private async Task<FlowScope.Resources.IResourceHandle<T>> AwaitHandle<T>()
|
||||
where T : class
|
||||
{
|
||||
var handle = await _gate.Task;
|
||||
Assert.That(handle.Asset, Is.SameAs(_asset));
|
||||
return (FlowScope.Resources.IResourceHandle<T>)(object)handle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user