Files
FlowScope/My project/Assets/FlowScope/Tests/EditMode/Flow/GameFlowTests.cs
2026-05-18 10:28:55 +08:00

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");
}
}
}
}