diff --git a/My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs b/My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs new file mode 100644 index 0000000..5c01986 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs @@ -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; + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs.meta b/My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs.meta new file mode 100644 index 0000000..7ba1d72 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a5f4e3fb56394e719bff30e8edd4abaa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs b/My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs new file mode 100644 index 0000000..f1883b0 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs @@ -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( + 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(); + await config.LoadAllAsync(cancellationToken); + + (feature, context) = CreateFeature(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(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(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( + CancellationToken cancellationToken) + where TFeature : IFeature + { + ContainerType scope = null; + IResourceGroup resources = null; + FeatureContext context = null; + + try + { + scope = _root.CreateScope(); + var resourceService = _root.Resolve(); + resources = resourceService.CreateGroup(); + context = new FeatureContext( + scope, + resources, + new CompositeDisposable(), + cancellationToken); + var feature = scope.Resolve(); + 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}."); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs.meta b/My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs.meta new file mode 100644 index 0000000..56ab886 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a533908611b643259dbfcb681369d012 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Flow.meta b/My project/Assets/FlowScope/Tests/EditMode/Flow.meta new file mode 100644 index 0000000..c50d24a --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Flow.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b3be35091c79468180dfa23774e4ff2b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Flow/FeatureBaseTests.cs b/My project/Assets/FlowScope/Tests/EditMode/Flow/FeatureBaseTests.cs new file mode 100644 index 0000000..00fa1f0 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Flow/FeatureBaseTests.cs @@ -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(IResourceHandle handle) + where T : class + { + } + + public void Dispose() + { + } + } + } +} diff --git a/My project/Assets/FlowScope/Tests/EditMode/Flow/FeatureBaseTests.cs.meta b/My project/Assets/FlowScope/Tests/EditMode/Flow/FeatureBaseTests.cs.meta new file mode 100644 index 0000000..355a8ba --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Flow/FeatureBaseTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bdd19af912bd408489e848bdfc88278d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs b/My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs new file mode 100644 index 0000000..c1e6167 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs @@ -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(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(root, CancellationToken.None); + + await flow.SwitchToAsync(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( + async () => await flow.StartupAsync(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(root, CancellationToken.None); + + Assert.ThrowsAsync( + async () => await flow.SwitchToAsync(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( + async () => await flow.SwitchToAsync(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(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(root, CancellationToken.None); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.ThrowsAsync( + 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(Config); + root.RegisterInstance(Save); + root.RegisterInstance(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(int id) + where T : class, IConfigRow => null; + + public IReadOnlyList GetAll() + where T : class, IConfigRow => Array.Empty(); + } + + private sealed class TestSaveService : ISaveService + { + public Task SaveAsync(string key, T data, CancellationToken cancellationToken) => Task.CompletedTask; + public Task LoadAsync(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 Groups { get; } = new(); + + public Task> LoadAsync(string key, CancellationToken cancellationToken) + where T : class => Task.FromResult>(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(IResourceHandle handle) + where T : class + { + } + + public void Dispose() + { + IsDisposed = true; + } + } + + private abstract class RecordingFeature : IFeature + { + public List 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"); + } + } + } +} diff --git a/My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs.meta b/My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs.meta new file mode 100644 index 0000000..45a963e --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12268dd1cada4c7bbe174bd1d276b87a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: