修复 PlayMode 测试主线程阻塞

This commit is contained in:
JSD\13999
2026-05-19 10:53:26 +08:00
parent 48785f9f6a
commit 23d235dad6
3 changed files with 150 additions and 47 deletions

View File

@@ -1,15 +1,17 @@
using System;
using System.Collections;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources;
using NUnit.Framework;
using UnityEngine.TestTools;
namespace FlowScope.Tests.PlayMode.Resources
{
public sealed class AddressablesResourceServiceTests
{
[Test]
public void LoadAsync_WhenSameKeyLoadsConcurrently_SharesOneBackendLoad()
[UnityTest]
public IEnumerator LoadAsync_WhenSameKeyLoadsConcurrently_SharesOneBackendLoad()
{
var backendLoads = 0;
var releaseCount = 0;
@@ -26,8 +28,10 @@ namespace FlowScope.Tests.PlayMode.Resources
var second = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
gate.SetResult(new TestAsset());
var firstHandle = first.GetAwaiter().GetResult();
var secondHandle = second.GetAwaiter().GetResult();
IResourceHandle<TestAsset> firstHandle = null;
IResourceHandle<TestAsset> secondHandle = null;
yield return AwaitTask(first, handle => firstHandle = handle);
yield return AwaitTask(second, handle => secondHandle = handle);
Assert.AreEqual(1, backendLoads);
Assert.AreSame(firstHandle.Asset, secondHandle.Asset);
@@ -38,34 +42,58 @@ namespace FlowScope.Tests.PlayMode.Resources
Assert.AreEqual(1, releaseCount);
}
[Test]
public void LoadAsync_WhenBackendFails_IncludesKeyAndBackend()
[UnityTest]
public IEnumerator LoadAsync_WhenBackendFails_IncludesKeyAndBackend()
{
var service = new AddressablesResourceService(
(_, _, _) => throw new InvalidOperationException("missing"),
(_, _) => { });
var exception = ThrowsAsync<InvalidOperationException>(
() => service.LoadAsync<TestAsset>("missing-key", CancellationToken.None));
InvalidOperationException exception = null;
yield return ThrowsAsync<InvalidOperationException>(
() => service.LoadAsync<TestAsset>("missing-key", CancellationToken.None),
caught => exception = caught);
StringAssert.Contains("missing-key", exception.Message);
StringAssert.Contains("Addressables", exception.Message);
}
private static TException ThrowsAsync<TException>(Func<Task> action)
private static IEnumerator ThrowsAsync<TException>(Func<Task> action, Action<TException> onCaught)
where TException : Exception
{
try
var task = action();
while (!task.IsCompleted)
{
action().GetAwaiter().GetResult();
yield return null;
}
catch (TException exception)
if (task.IsFaulted && task.Exception?.GetBaseException() is TException exception)
{
return exception;
onCaught(exception);
yield break;
}
Assert.Fail($"Expected {typeof(TException).Name}.");
return null;
}
private static IEnumerator AwaitTask<T>(Task<T> task, Action<T> onCompleted)
{
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
throw task.Exception.GetBaseException();
}
if (task.IsCanceled)
{
throw new OperationCanceledException();
}
onCompleted(task.Result);
}
private sealed class TestAsset

View File

@@ -1,9 +1,13 @@
using System;
using System.Collections;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Samples.MainMenuP0;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.UI;
using Object = UnityEngine.Object;
namespace FlowScope.Tests.PlayMode.Samples
{
@@ -20,8 +24,8 @@ namespace FlowScope.Tests.PlayMode.Samples
Assert.AreEqual(1, data.Gold.Value);
}
[Test]
public void Bootstrap_CanConstructAndShutdownWithoutSceneAsset()
[UnityTest]
public IEnumerator Bootstrap_CanConstructAndShutdownWithoutSceneAsset()
{
var root = new GameObject("bootstrap-test");
var panelObject = new GameObject("panel-prefab");
@@ -36,10 +40,28 @@ namespace FlowScope.Tests.PlayMode.Samples
panel.Configure(label, button);
var bootstrap = root.AddComponent<GameBootstrap>();
bootstrap.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult();
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
Object.DestroyImmediate(panelObject);
Object.DestroyImmediate(root);
}
private static IEnumerator AwaitTask(Task task)
{
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
throw task.Exception.GetBaseException();
}
if (task.IsCanceled)
{
throw new OperationCanceledException();
}
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -6,6 +7,7 @@ using FlowScope.Resources;
using FlowScope.UI;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace FlowScope.Tests.PlayMode.UI
{
@@ -27,8 +29,8 @@ namespace FlowScope.Tests.PlayMode.UI
_objects.Clear();
}
[Test]
public void OpenAsync_UsesAttributePathAndMountsPanelToRegisteredLayer()
[UnityTest]
public IEnumerator OpenAsync_UsesAttributePathAndMountsPanelToRegisteredLayer()
{
var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
@@ -38,7 +40,10 @@ namespace FlowScope.Tests.PlayMode.UI
manager.RegisterLayer("popup", canvas, 120);
var viewModel = new TestViewModel("first");
var panel = manager.OpenAsync<ExplicitPathPanel, TestViewModel>(viewModel, CancellationToken.None).GetAwaiter().GetResult();
ExplicitPathPanel panel = null;
yield return AwaitTask(
manager.OpenAsync<ExplicitPathPanel, TestViewModel>(viewModel, CancellationToken.None),
result => panel = result);
Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" }));
Assert.That(panel.transform.parent, Is.EqualTo(canvas.transform));
@@ -48,8 +53,8 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
}
[Test]
public void OpenAsync_WhenPathIsMissing_UsesPanelNameConvention()
[UnityTest]
public IEnumerator OpenAsync_WhenPathIsMissing_UsesPanelNameConvention()
{
var canvas = CreateCanvas("hud");
var prefab = CreatePrefab<MainHudPanel>("MainHudPrefab");
@@ -58,13 +63,13 @@ namespace FlowScope.Tests.PlayMode.UI
var manager = new UIManager(resources);
manager.RegisterLayer("hud", canvas, 10);
manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None).GetAwaiter().GetResult();
yield return AwaitTask(manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None));
Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "UI/MainHud/Prefab" }));
}
[Test]
public void Close_WhenPanelIsNotLayerTop_ThrowsInvalidOperationException()
[UnityTest]
public IEnumerator Close_WhenPanelIsNotLayerTop_ThrowsInvalidOperationException()
{
var canvas = CreateCanvas("popup");
var resources = new FakeResourceService();
@@ -72,16 +77,16 @@ namespace FlowScope.Tests.PlayMode.UI
resources.Register("UI/Second/Prefab", CreatePrefab<SecondPanel>("SecondPrefab"));
var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0);
manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None).GetAwaiter().GetResult();
manager.OpenAsync<SecondPanel, TestViewModel>(new TestViewModel("second"), CancellationToken.None).GetAwaiter().GetResult();
yield return AwaitTask(manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None));
yield return AwaitTask(manager.OpenAsync<SecondPanel, TestViewModel>(new TestViewModel("second"), CancellationToken.None));
Assert.Throws<InvalidOperationException>(() => manager.Close<ExplicitPathPanel>());
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
Assert.That(manager.IsOpen<SecondPanel>(), Is.True);
}
[Test]
public void Close_WithDestroyStrategy_UnbindsDestroysInstanceAndDisposesHandle()
[UnityTest]
public IEnumerator Close_WithDestroyStrategy_UnbindsDestroysInstanceAndDisposesHandle()
{
var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
@@ -89,7 +94,10 @@ namespace FlowScope.Tests.PlayMode.UI
resources.Register("Custom/Explicit", prefab);
var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0, PanelStrategy.Destroy);
var panel = manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None).GetAwaiter().GetResult();
ExplicitPathPanel panel = null;
yield return AwaitTask(
manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None),
result => panel = result);
var handle = resources.Handles[0];
manager.Close<ExplicitPathPanel>();
@@ -100,8 +108,8 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.False);
}
[Test]
public void OpenAsync_WithCacheStrategy_ReusesHiddenInstanceAndBindsAgain()
[UnityTest]
public IEnumerator OpenAsync_WithCacheStrategy_ReusesHiddenInstanceAndBindsAgain()
{
var canvas = CreateCanvas("dialog");
var prefab = CreatePrefab<CachedPanel>("CachedPrefab");
@@ -112,9 +120,15 @@ namespace FlowScope.Tests.PlayMode.UI
var firstViewModel = new TestViewModel("first");
var secondViewModel = new TestViewModel("second");
var first = manager.OpenAsync<CachedPanel, TestViewModel>(firstViewModel, CancellationToken.None).GetAwaiter().GetResult();
CachedPanel first = null;
yield return AwaitTask(
manager.OpenAsync<CachedPanel, TestViewModel>(firstViewModel, CancellationToken.None),
result => first = result);
manager.Close<CachedPanel>();
var second = manager.OpenAsync<CachedPanel, TestViewModel>(secondViewModel, CancellationToken.None).GetAwaiter().GetResult();
CachedPanel second = null;
yield return AwaitTask(
manager.OpenAsync<CachedPanel, TestViewModel>(secondViewModel, CancellationToken.None),
result => second = result);
Assert.That(second, Is.SameAs(first));
Assert.That(second.gameObject.activeSelf, Is.True);
@@ -124,8 +138,8 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(resources.Handles[0].IsDisposed, Is.False);
}
[Test]
public void CloseLayer_OnlyClosesRequestedLayer()
[UnityTest]
public IEnumerator CloseLayer_OnlyClosesRequestedLayer()
{
var popupCanvas = CreateCanvas("popup");
var hudCanvas = CreateCanvas("hud");
@@ -135,8 +149,8 @@ namespace FlowScope.Tests.PlayMode.UI
var manager = new UIManager(resources);
manager.RegisterLayer("popup", popupCanvas, 0);
manager.RegisterLayer("hud", hudCanvas, 0);
manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("popup"), CancellationToken.None).GetAwaiter().GetResult();
manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None).GetAwaiter().GetResult();
yield return AwaitTask(manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("popup"), CancellationToken.None));
yield return AwaitTask(manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None));
manager.CloseLayer("popup");
@@ -144,8 +158,8 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(manager.IsOpen<MainHudPanel>(), Is.True);
}
[Test]
public void OpenAsync_WhenBindThrows_DisposesHandleAndDestroysCreatedInstance()
[UnityTest]
public IEnumerator OpenAsync_WhenBindThrows_DisposesHandleAndDestroysCreatedInstance()
{
var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ThrowingBindPanel>("ThrowingPrefab");
@@ -154,7 +168,7 @@ namespace FlowScope.Tests.PlayMode.UI
var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0);
ThrowsAsync<InvalidOperationException>(
yield return ThrowsAsync<InvalidOperationException>(
() => manager.OpenAsync<ThrowingBindPanel, TestViewModel>(new TestViewModel("boom"), CancellationToken.None));
Assert.That(resources.Handles[0].IsDisposed, Is.True);
@@ -169,20 +183,21 @@ namespace FlowScope.Tests.PlayMode.UI
return gameObject.AddComponent<Canvas>();
}
private static TException ThrowsAsync<TException>(Func<Task> action)
private static IEnumerator ThrowsAsync<TException>(Func<Task> action)
where TException : Exception
{
try
var task = action();
while (!task.IsCompleted)
{
action().GetAwaiter().GetResult();
yield return null;
}
catch (TException exception)
if (task.IsFaulted && task.Exception?.GetBaseException() is TException)
{
return exception;
yield break;
}
Assert.Fail($"Expected {typeof(TException).Name}.");
return null;
}
private GameObject CreatePrefab<TPanel>(string name)
@@ -194,6 +209,44 @@ namespace FlowScope.Tests.PlayMode.UI
return gameObject;
}
private static IEnumerator AwaitTask(Task task)
{
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
throw task.Exception.GetBaseException();
}
if (task.IsCanceled)
{
throw new OperationCanceledException();
}
}
private static IEnumerator AwaitTask<T>(Task<T> task, Action<T> onCompleted)
{
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
throw task.Exception.GetBaseException();
}
if (task.IsCanceled)
{
throw new OperationCanceledException();
}
onCompleted(task.Result);
}
private sealed class FakeResourceService : IResourceService
{
private readonly Dictionary<string, GameObject> _assets = new();