319 lines
11 KiB
C#
319 lines
11 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|
|
}
|