合并 P0 生命周期编排
# Conflicts: # My project/Assets/FlowScope/Tests.meta # My project/Assets/FlowScope/Tests/EditMode.meta
This commit is contained in:
31
My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs
Normal file
31
My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Flow
|
||||||
|
{
|
||||||
|
public abstract class FeatureBase : IFeature
|
||||||
|
{
|
||||||
|
protected FeatureContext Context { get; private set; }
|
||||||
|
|
||||||
|
public virtual Task LoadAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
Context = context;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task EnterAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task ExitAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
context?.Disposables.Clear();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void Dispose()
|
||||||
|
{
|
||||||
|
Context = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a5f4e3fb56394e719bff30e8edd4abaa
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
285
My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs
Normal file
285
My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Config;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using R3;
|
||||||
|
using UnityEngine;
|
||||||
|
using ContainerType = FlowScope.Container.Container;
|
||||||
|
|
||||||
|
namespace FlowScope.Flow
|
||||||
|
{
|
||||||
|
public sealed class GameFlow
|
||||||
|
{
|
||||||
|
private ContainerType _root;
|
||||||
|
private IFeature _activeFeature;
|
||||||
|
private FeatureContext _activeContext;
|
||||||
|
|
||||||
|
public GameFlowState State { get; private set; } = GameFlowState.Idle;
|
||||||
|
|
||||||
|
public async Task StartupAsync<TInitialFeature>(
|
||||||
|
ContainerType root,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where TInitialFeature : IFeature
|
||||||
|
{
|
||||||
|
EnsureState(nameof(StartupAsync), GameFlowState.Idle);
|
||||||
|
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||||
|
State = GameFlowState.Starting;
|
||||||
|
|
||||||
|
IFeature feature = null;
|
||||||
|
FeatureContext context = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = _root.Resolve<IConfigProvider>();
|
||||||
|
await config.LoadAllAsync(cancellationToken);
|
||||||
|
|
||||||
|
(feature, context) = CreateFeature<TInitialFeature>(cancellationToken);
|
||||||
|
await feature.LoadAsync(context);
|
||||||
|
await feature.EnterAsync(context);
|
||||||
|
|
||||||
|
_activeFeature = feature;
|
||||||
|
_activeContext = context;
|
||||||
|
State = GameFlowState.Running;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
BestEffortDisposeFeature(feature);
|
||||||
|
BestEffortDisposeContext(context);
|
||||||
|
_activeFeature = null;
|
||||||
|
_activeContext = null;
|
||||||
|
State = GameFlowState.Idle;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SwitchToAsync<TFeature>(CancellationToken cancellationToken)
|
||||||
|
where TFeature : IFeature
|
||||||
|
{
|
||||||
|
EnsureState(
|
||||||
|
nameof(SwitchToAsync),
|
||||||
|
GameFlowState.Running,
|
||||||
|
GameFlowState.NoActiveFeature);
|
||||||
|
|
||||||
|
State = GameFlowState.Switching;
|
||||||
|
await CleanupActiveFeatureAsync();
|
||||||
|
|
||||||
|
IFeature nextFeature = null;
|
||||||
|
FeatureContext nextContext = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(nextFeature, nextContext) = CreateFeature<TFeature>(cancellationToken);
|
||||||
|
await nextFeature.LoadAsync(nextContext);
|
||||||
|
await nextFeature.EnterAsync(nextContext);
|
||||||
|
|
||||||
|
_activeFeature = nextFeature;
|
||||||
|
_activeContext = nextContext;
|
||||||
|
State = GameFlowState.Running;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
BestEffortDisposeFeature(nextFeature);
|
||||||
|
BestEffortDisposeContext(nextContext);
|
||||||
|
_activeFeature = null;
|
||||||
|
_activeContext = null;
|
||||||
|
State = GameFlowState.NoActiveFeature;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureState(
|
||||||
|
nameof(ShutdownAsync),
|
||||||
|
GameFlowState.Running,
|
||||||
|
GameFlowState.NoActiveFeature);
|
||||||
|
|
||||||
|
State = GameFlowState.ShuttingDown;
|
||||||
|
Exception cancellationException = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException exception)
|
||||||
|
{
|
||||||
|
cancellationException = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CleanupActiveFeatureAsync();
|
||||||
|
BestEffortDisposeRoot();
|
||||||
|
State = GameFlowState.Disposed;
|
||||||
|
|
||||||
|
if (cancellationException != null)
|
||||||
|
{
|
||||||
|
throw cancellationException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private (IFeature Feature, FeatureContext Context) CreateFeature<TFeature>(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where TFeature : IFeature
|
||||||
|
{
|
||||||
|
ContainerType scope = null;
|
||||||
|
IResourceGroup resources = null;
|
||||||
|
FeatureContext context = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scope = _root.CreateScope();
|
||||||
|
var resourceService = _root.Resolve<IResourceService>();
|
||||||
|
resources = resourceService.CreateGroup();
|
||||||
|
context = new FeatureContext(
|
||||||
|
scope,
|
||||||
|
resources,
|
||||||
|
new CompositeDisposable(),
|
||||||
|
cancellationToken);
|
||||||
|
var feature = scope.Resolve<TFeature>();
|
||||||
|
return (feature, context);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (context != null)
|
||||||
|
{
|
||||||
|
BestEffortDisposeContext(context);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
BestEffortDisposePartialContext(resources, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanupActiveFeatureAsync()
|
||||||
|
{
|
||||||
|
var feature = _activeFeature;
|
||||||
|
var context = _activeContext;
|
||||||
|
_activeFeature = null;
|
||||||
|
_activeContext = null;
|
||||||
|
|
||||||
|
if (feature != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await feature.ExitAsync(context);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Disposables.Clear();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Resources?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Scope?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BestEffortDisposePartialContext(
|
||||||
|
IResourceGroup resources,
|
||||||
|
ContainerType scope)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
resources?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scope?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BestEffortDisposeRoot()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_root?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_root = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureState(string operation, params GameFlowState[] allowedStates)
|
||||||
|
{
|
||||||
|
foreach (var allowedState in allowedStates)
|
||||||
|
{
|
||||||
|
if (State == allowedState)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{operation} cannot run while GameFlow state is {State}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a533908611b643259dbfcb681369d012
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Tests/EditMode/Flow.meta
Normal file
8
My project/Assets/FlowScope/Tests/EditMode/Flow.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b3be35091c79468180dfa23774e4ff2b
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Container;
|
||||||
|
using FlowScope.Flow;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using R3;
|
||||||
|
|
||||||
|
namespace FlowScope.Tests.EditMode.Flow
|
||||||
|
{
|
||||||
|
public sealed class FeatureBaseTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_StoresContext()
|
||||||
|
{
|
||||||
|
var context = CreateContext();
|
||||||
|
var feature = new TestFeature();
|
||||||
|
|
||||||
|
await feature.LoadAsync(context);
|
||||||
|
|
||||||
|
Assert.That(feature.ExposedContext, Is.SameAs(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ExitAsync_ClearsContextDisposables()
|
||||||
|
{
|
||||||
|
var context = CreateContext();
|
||||||
|
var disposable = new SpyDisposable();
|
||||||
|
context.Disposables.Add(disposable);
|
||||||
|
var feature = new TestFeature();
|
||||||
|
|
||||||
|
await feature.ExitAsync(context);
|
||||||
|
|
||||||
|
Assert.That(disposable.IsDisposed, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Dispose_IsIdempotentAndClearsContextReference()
|
||||||
|
{
|
||||||
|
var context = CreateContext();
|
||||||
|
var feature = new TestFeature();
|
||||||
|
feature.LoadAsync(context).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
feature.Dispose();
|
||||||
|
feature.Dispose();
|
||||||
|
|
||||||
|
Assert.That(feature.ExposedContext, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FeatureContext CreateContext()
|
||||||
|
{
|
||||||
|
return new FeatureContext(
|
||||||
|
new Container(),
|
||||||
|
new TestResourceGroup(),
|
||||||
|
new CompositeDisposable(),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestFeature : FeatureBase
|
||||||
|
{
|
||||||
|
public FeatureContext ExposedContext => Context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SpyDisposable : IDisposable
|
||||||
|
{
|
||||||
|
public bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IsDisposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestResourceGroup : IResourceGroup
|
||||||
|
{
|
||||||
|
public int Count => 0;
|
||||||
|
|
||||||
|
public void Add<T>(IResourceHandle<T> handle)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bdd19af912bd408489e848bdfc88278d
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
318
My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs
Normal file
318
My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Config;
|
||||||
|
using FlowScope.Container;
|
||||||
|
using FlowScope.Flow;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using FlowScope.Save;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace FlowScope.Tests.EditMode.Flow
|
||||||
|
{
|
||||||
|
public sealed class GameFlowTests
|
||||||
|
{
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
FirstFeature.Instance = null;
|
||||||
|
SecondFeature.Instance = null;
|
||||||
|
FailingLoadFeature.Instance = null;
|
||||||
|
FailingEnterFeature.Instance = null;
|
||||||
|
ThrowingExitAndDisposeFeature.Instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task StartupAsync_LoadsConfigCreatesContextAndEntersInitialFeature()
|
||||||
|
{
|
||||||
|
var services = new TestServices();
|
||||||
|
var root = services.CreateRoot();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var flow = new GameFlow();
|
||||||
|
|
||||||
|
await flow.StartupAsync<FirstFeature>(root, cts.Token);
|
||||||
|
|
||||||
|
Assert.That(flow.State, Is.EqualTo(GameFlowState.Running));
|
||||||
|
Assert.That(services.Config.LoadCalls, Is.EqualTo(1));
|
||||||
|
Assert.That(FirstFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter" }));
|
||||||
|
Assert.That(FirstFeature.Instance.Context.CancellationToken, Is.EqualTo(cts.Token));
|
||||||
|
Assert.That(FirstFeature.Instance.Context.Scope, Is.Not.SameAs(root));
|
||||||
|
Assert.That(FirstFeature.Instance.Context.Resources, Is.SameAs(services.Resource.Groups[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SwitchToAsync_ExitsDisposesAndReleasesOldContextBeforeEnteringNextFeature()
|
||||||
|
{
|
||||||
|
var services = new TestServices();
|
||||||
|
var root = services.CreateRoot();
|
||||||
|
var flow = new GameFlow();
|
||||||
|
await flow.StartupAsync<FirstFeature>(root, CancellationToken.None);
|
||||||
|
|
||||||
|
await flow.SwitchToAsync<SecondFeature>(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(flow.State, Is.EqualTo(GameFlowState.Running));
|
||||||
|
Assert.That(FirstFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter", "Exit", "Dispose" }));
|
||||||
|
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
|
||||||
|
Assert.That(SecondFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter" }));
|
||||||
|
Assert.That(SecondFeature.Instance.Context.Resources, Is.SameAs(services.Resource.Groups[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void StartupAsync_WhenFeatureLoadFails_CleansCreatedFeatureAndReturnsIdle()
|
||||||
|
{
|
||||||
|
var services = new TestServices();
|
||||||
|
var root = services.CreateRoot();
|
||||||
|
var flow = new GameFlow();
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
async () => await flow.StartupAsync<FailingLoadFeature>(root, CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.That(flow.State, Is.EqualTo(GameFlowState.Idle));
|
||||||
|
Assert.That(FailingLoadFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Dispose" }));
|
||||||
|
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SwitchToAsync_WhenNextEnterFails_CleansNextFeatureAndLeavesNoActiveFeature()
|
||||||
|
{
|
||||||
|
var services = new TestServices();
|
||||||
|
var root = services.CreateRoot();
|
||||||
|
var flow = new GameFlow();
|
||||||
|
await flow.StartupAsync<FirstFeature>(root, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
async () => await flow.SwitchToAsync<FailingEnterFeature>(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.That(flow.State, Is.EqualTo(GameFlowState.NoActiveFeature));
|
||||||
|
Assert.That(FirstFeature.Instance.Calls, Does.Contain("Dispose"));
|
||||||
|
Assert.That(FailingEnterFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter", "Dispose" }));
|
||||||
|
Assert.That(services.Resource.Groups[1].IsDisposed, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SwitchToAsync_WhenIdle_ThrowsWithOperationAndCurrentState()
|
||||||
|
{
|
||||||
|
var flow = new GameFlow();
|
||||||
|
|
||||||
|
var exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
async () => await flow.SwitchToAsync<FirstFeature>(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.That(exception.Message, Does.Contain(nameof(GameFlow.SwitchToAsync)));
|
||||||
|
Assert.That(exception.Message, Does.Contain(GameFlowState.Idle.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShutdownAsync_WhenFeatureCleanupThrows_StillReachesDisposed()
|
||||||
|
{
|
||||||
|
var services = new TestServices();
|
||||||
|
var root = services.CreateRoot();
|
||||||
|
var flow = new GameFlow();
|
||||||
|
await flow.StartupAsync<ThrowingExitAndDisposeFeature>(root, CancellationToken.None);
|
||||||
|
|
||||||
|
await flow.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(flow.State, Is.EqualTo(GameFlowState.Disposed));
|
||||||
|
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShutdownAsync_WhenCancellationIsAlreadyRequested_CleansUpThenThrowsCancellation()
|
||||||
|
{
|
||||||
|
var services = new TestServices();
|
||||||
|
var root = services.CreateRoot();
|
||||||
|
var flow = new GameFlow();
|
||||||
|
await flow.StartupAsync<FirstFeature>(root, CancellationToken.None);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<OperationCanceledException>(
|
||||||
|
async () => await flow.ShutdownAsync(cts.Token));
|
||||||
|
|
||||||
|
Assert.That(flow.State, Is.EqualTo(GameFlowState.Disposed));
|
||||||
|
Assert.That(FirstFeature.Instance.Calls, Does.Contain("Dispose"));
|
||||||
|
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestServices
|
||||||
|
{
|
||||||
|
public TestConfigProvider Config { get; } = new();
|
||||||
|
public TestSaveService Save { get; } = new();
|
||||||
|
public TestResourceService Resource { get; } = new();
|
||||||
|
|
||||||
|
public Container CreateRoot()
|
||||||
|
{
|
||||||
|
var root = new Container();
|
||||||
|
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());
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestConfigProvider : IConfigProvider
|
||||||
|
{
|
||||||
|
public int LoadCalls { get; private set; }
|
||||||
|
|
||||||
|
public Task LoadAllAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LoadCalls++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Get<T>(int id)
|
||||||
|
where T : class, IConfigRow => null;
|
||||||
|
|
||||||
|
public IReadOnlyList<T> GetAll<T>()
|
||||||
|
where T : class, IConfigRow => Array.Empty<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestSaveService : ISaveService
|
||||||
|
{
|
||||||
|
public Task SaveAsync<T>(string key, T data, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task<T> LoadAsync<T>(string key, T defaultValue, CancellationToken cancellationToken) => Task.FromResult(defaultValue);
|
||||||
|
public void Delete(string key) { }
|
||||||
|
public bool Exists(string key) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestResourceService : IResourceService
|
||||||
|
{
|
||||||
|
public List<TestResourceGroup> Groups { get; } = new();
|
||||||
|
|
||||||
|
public Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken)
|
||||||
|
where T : class => Task.FromResult<IResourceHandle<T>>(null);
|
||||||
|
|
||||||
|
public IResourceGroup CreateGroup()
|
||||||
|
{
|
||||||
|
var group = new TestResourceGroup();
|
||||||
|
Groups.Add(group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestResourceGroup : IResourceGroup
|
||||||
|
{
|
||||||
|
public bool IsDisposed { get; private set; }
|
||||||
|
public int Count => 0;
|
||||||
|
|
||||||
|
public void Add<T>(IResourceHandle<T> handle)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IsDisposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private abstract class RecordingFeature : IFeature
|
||||||
|
{
|
||||||
|
public List<string> Calls { get; } = new();
|
||||||
|
public FeatureContext Context { get; private set; }
|
||||||
|
|
||||||
|
public virtual Task LoadAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
Context = context;
|
||||||
|
Calls.Add("Load");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task EnterAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
Calls.Add("Enter");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task ExitAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
Calls.Add("Exit");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void Dispose()
|
||||||
|
{
|
||||||
|
Calls.Add("Dispose");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FirstFeature : RecordingFeature
|
||||||
|
{
|
||||||
|
public static FirstFeature Instance { get; set; }
|
||||||
|
|
||||||
|
public FirstFeature()
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SecondFeature : RecordingFeature
|
||||||
|
{
|
||||||
|
public static SecondFeature Instance { get; set; }
|
||||||
|
|
||||||
|
public SecondFeature()
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingLoadFeature : RecordingFeature
|
||||||
|
{
|
||||||
|
public static FailingLoadFeature Instance { get; set; }
|
||||||
|
|
||||||
|
public FailingLoadFeature()
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task LoadAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
base.LoadAsync(context);
|
||||||
|
throw new InvalidOperationException("load failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingEnterFeature : RecordingFeature
|
||||||
|
{
|
||||||
|
public static FailingEnterFeature Instance { get; set; }
|
||||||
|
|
||||||
|
public FailingEnterFeature()
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task EnterAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
base.EnterAsync(context);
|
||||||
|
throw new InvalidOperationException("enter failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingExitAndDisposeFeature : RecordingFeature
|
||||||
|
{
|
||||||
|
public static ThrowingExitAndDisposeFeature Instance { get; set; }
|
||||||
|
|
||||||
|
public ThrowingExitAndDisposeFeature()
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task ExitAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
base.ExitAsync(context);
|
||||||
|
throw new InvalidOperationException("exit failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Dispose()
|
||||||
|
{
|
||||||
|
base.Dispose();
|
||||||
|
throw new InvalidOperationException("dispose failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 12268dd1cada4c7bbe174bd1d276b87a
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Reference in New Issue
Block a user