修复 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;
using System.Collections;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FlowScope.Resources; using FlowScope.Resources;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine.TestTools;
namespace FlowScope.Tests.PlayMode.Resources namespace FlowScope.Tests.PlayMode.Resources
{ {
public sealed class AddressablesResourceServiceTests public sealed class AddressablesResourceServiceTests
{ {
[Test] [UnityTest]
public void LoadAsync_WhenSameKeyLoadsConcurrently_SharesOneBackendLoad() public IEnumerator LoadAsync_WhenSameKeyLoadsConcurrently_SharesOneBackendLoad()
{ {
var backendLoads = 0; var backendLoads = 0;
var releaseCount = 0; var releaseCount = 0;
@@ -26,8 +28,10 @@ namespace FlowScope.Tests.PlayMode.Resources
var second = service.LoadAsync<TestAsset>("shared", CancellationToken.None); var second = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
gate.SetResult(new TestAsset()); gate.SetResult(new TestAsset());
var firstHandle = first.GetAwaiter().GetResult(); IResourceHandle<TestAsset> firstHandle = null;
var secondHandle = second.GetAwaiter().GetResult(); IResourceHandle<TestAsset> secondHandle = null;
yield return AwaitTask(first, handle => firstHandle = handle);
yield return AwaitTask(second, handle => secondHandle = handle);
Assert.AreEqual(1, backendLoads); Assert.AreEqual(1, backendLoads);
Assert.AreSame(firstHandle.Asset, secondHandle.Asset); Assert.AreSame(firstHandle.Asset, secondHandle.Asset);
@@ -38,34 +42,58 @@ namespace FlowScope.Tests.PlayMode.Resources
Assert.AreEqual(1, releaseCount); Assert.AreEqual(1, releaseCount);
} }
[Test] [UnityTest]
public void LoadAsync_WhenBackendFails_IncludesKeyAndBackend() public IEnumerator LoadAsync_WhenBackendFails_IncludesKeyAndBackend()
{ {
var service = new AddressablesResourceService( var service = new AddressablesResourceService(
(_, _, _) => throw new InvalidOperationException("missing"), (_, _, _) => throw new InvalidOperationException("missing"),
(_, _) => { }); (_, _) => { });
var exception = ThrowsAsync<InvalidOperationException>( InvalidOperationException exception = null;
() => service.LoadAsync<TestAsset>("missing-key", CancellationToken.None)); yield return ThrowsAsync<InvalidOperationException>(
() => service.LoadAsync<TestAsset>("missing-key", CancellationToken.None),
caught => exception = caught);
StringAssert.Contains("missing-key", exception.Message); StringAssert.Contains("missing-key", exception.Message);
StringAssert.Contains("Addressables", 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 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}."); 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 private sealed class TestAsset

View File

@@ -1,9 +1,13 @@
using System;
using System.Collections;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FlowScope.Samples.MainMenuP0; using FlowScope.Samples.MainMenuP0;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine; using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.UI; using UnityEngine.UI;
using Object = UnityEngine.Object;
namespace FlowScope.Tests.PlayMode.Samples namespace FlowScope.Tests.PlayMode.Samples
{ {
@@ -20,8 +24,8 @@ namespace FlowScope.Tests.PlayMode.Samples
Assert.AreEqual(1, data.Gold.Value); Assert.AreEqual(1, data.Gold.Value);
} }
[Test] [UnityTest]
public void Bootstrap_CanConstructAndShutdownWithoutSceneAsset() public IEnumerator Bootstrap_CanConstructAndShutdownWithoutSceneAsset()
{ {
var root = new GameObject("bootstrap-test"); var root = new GameObject("bootstrap-test");
var panelObject = new GameObject("panel-prefab"); var panelObject = new GameObject("panel-prefab");
@@ -36,10 +40,28 @@ namespace FlowScope.Tests.PlayMode.Samples
panel.Configure(label, button); panel.Configure(label, button);
var bootstrap = root.AddComponent<GameBootstrap>(); var bootstrap = root.AddComponent<GameBootstrap>();
bootstrap.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
Object.DestroyImmediate(panelObject); Object.DestroyImmediate(panelObject);
Object.DestroyImmediate(root); 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;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -6,6 +7,7 @@ using FlowScope.Resources;
using FlowScope.UI; using FlowScope.UI;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine; using UnityEngine;
using UnityEngine.TestTools;
namespace FlowScope.Tests.PlayMode.UI namespace FlowScope.Tests.PlayMode.UI
{ {
@@ -27,8 +29,8 @@ namespace FlowScope.Tests.PlayMode.UI
_objects.Clear(); _objects.Clear();
} }
[Test] [UnityTest]
public void OpenAsync_UsesAttributePathAndMountsPanelToRegisteredLayer() public IEnumerator OpenAsync_UsesAttributePathAndMountsPanelToRegisteredLayer()
{ {
var canvas = CreateCanvas("popup"); var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab"); var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
@@ -38,7 +40,10 @@ namespace FlowScope.Tests.PlayMode.UI
manager.RegisterLayer("popup", canvas, 120); manager.RegisterLayer("popup", canvas, 120);
var viewModel = new TestViewModel("first"); 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(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" }));
Assert.That(panel.transform.parent, Is.EqualTo(canvas.transform)); 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); Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
} }
[Test] [UnityTest]
public void OpenAsync_WhenPathIsMissing_UsesPanelNameConvention() public IEnumerator OpenAsync_WhenPathIsMissing_UsesPanelNameConvention()
{ {
var canvas = CreateCanvas("hud"); var canvas = CreateCanvas("hud");
var prefab = CreatePrefab<MainHudPanel>("MainHudPrefab"); var prefab = CreatePrefab<MainHudPanel>("MainHudPrefab");
@@ -58,13 +63,13 @@ namespace FlowScope.Tests.PlayMode.UI
var manager = new UIManager(resources); var manager = new UIManager(resources);
manager.RegisterLayer("hud", canvas, 10); 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" })); Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "UI/MainHud/Prefab" }));
} }
[Test] [UnityTest]
public void Close_WhenPanelIsNotLayerTop_ThrowsInvalidOperationException() public IEnumerator Close_WhenPanelIsNotLayerTop_ThrowsInvalidOperationException()
{ {
var canvas = CreateCanvas("popup"); var canvas = CreateCanvas("popup");
var resources = new FakeResourceService(); var resources = new FakeResourceService();
@@ -72,16 +77,16 @@ namespace FlowScope.Tests.PlayMode.UI
resources.Register("UI/Second/Prefab", CreatePrefab<SecondPanel>("SecondPrefab")); resources.Register("UI/Second/Prefab", CreatePrefab<SecondPanel>("SecondPrefab"));
var manager = new UIManager(resources); var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0); manager.RegisterLayer("popup", canvas, 0);
manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None).GetAwaiter().GetResult(); yield return AwaitTask(manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None));
manager.OpenAsync<SecondPanel, TestViewModel>(new TestViewModel("second"), CancellationToken.None).GetAwaiter().GetResult(); yield return AwaitTask(manager.OpenAsync<SecondPanel, TestViewModel>(new TestViewModel("second"), CancellationToken.None));
Assert.Throws<InvalidOperationException>(() => manager.Close<ExplicitPathPanel>()); Assert.Throws<InvalidOperationException>(() => manager.Close<ExplicitPathPanel>());
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True); Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
Assert.That(manager.IsOpen<SecondPanel>(), Is.True); Assert.That(manager.IsOpen<SecondPanel>(), Is.True);
} }
[Test] [UnityTest]
public void Close_WithDestroyStrategy_UnbindsDestroysInstanceAndDisposesHandle() public IEnumerator Close_WithDestroyStrategy_UnbindsDestroysInstanceAndDisposesHandle()
{ {
var canvas = CreateCanvas("popup"); var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab"); var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
@@ -89,7 +94,10 @@ namespace FlowScope.Tests.PlayMode.UI
resources.Register("Custom/Explicit", prefab); resources.Register("Custom/Explicit", prefab);
var manager = new UIManager(resources); var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0, PanelStrategy.Destroy); 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]; var handle = resources.Handles[0];
manager.Close<ExplicitPathPanel>(); manager.Close<ExplicitPathPanel>();
@@ -100,8 +108,8 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.False); Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.False);
} }
[Test] [UnityTest]
public void OpenAsync_WithCacheStrategy_ReusesHiddenInstanceAndBindsAgain() public IEnumerator OpenAsync_WithCacheStrategy_ReusesHiddenInstanceAndBindsAgain()
{ {
var canvas = CreateCanvas("dialog"); var canvas = CreateCanvas("dialog");
var prefab = CreatePrefab<CachedPanel>("CachedPrefab"); var prefab = CreatePrefab<CachedPanel>("CachedPrefab");
@@ -112,9 +120,15 @@ namespace FlowScope.Tests.PlayMode.UI
var firstViewModel = new TestViewModel("first"); var firstViewModel = new TestViewModel("first");
var secondViewModel = new TestViewModel("second"); 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>(); 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, Is.SameAs(first));
Assert.That(second.gameObject.activeSelf, Is.True); Assert.That(second.gameObject.activeSelf, Is.True);
@@ -124,8 +138,8 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(resources.Handles[0].IsDisposed, Is.False); Assert.That(resources.Handles[0].IsDisposed, Is.False);
} }
[Test] [UnityTest]
public void CloseLayer_OnlyClosesRequestedLayer() public IEnumerator CloseLayer_OnlyClosesRequestedLayer()
{ {
var popupCanvas = CreateCanvas("popup"); var popupCanvas = CreateCanvas("popup");
var hudCanvas = CreateCanvas("hud"); var hudCanvas = CreateCanvas("hud");
@@ -135,8 +149,8 @@ namespace FlowScope.Tests.PlayMode.UI
var manager = new UIManager(resources); var manager = new UIManager(resources);
manager.RegisterLayer("popup", popupCanvas, 0); manager.RegisterLayer("popup", popupCanvas, 0);
manager.RegisterLayer("hud", hudCanvas, 0); manager.RegisterLayer("hud", hudCanvas, 0);
manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("popup"), CancellationToken.None).GetAwaiter().GetResult(); yield return AwaitTask(manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("popup"), CancellationToken.None));
manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None).GetAwaiter().GetResult(); yield return AwaitTask(manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None));
manager.CloseLayer("popup"); manager.CloseLayer("popup");
@@ -144,8 +158,8 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(manager.IsOpen<MainHudPanel>(), Is.True); Assert.That(manager.IsOpen<MainHudPanel>(), Is.True);
} }
[Test] [UnityTest]
public void OpenAsync_WhenBindThrows_DisposesHandleAndDestroysCreatedInstance() public IEnumerator OpenAsync_WhenBindThrows_DisposesHandleAndDestroysCreatedInstance()
{ {
var canvas = CreateCanvas("popup"); var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ThrowingBindPanel>("ThrowingPrefab"); var prefab = CreatePrefab<ThrowingBindPanel>("ThrowingPrefab");
@@ -154,7 +168,7 @@ namespace FlowScope.Tests.PlayMode.UI
var manager = new UIManager(resources); var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0); manager.RegisterLayer("popup", canvas, 0);
ThrowsAsync<InvalidOperationException>( yield return ThrowsAsync<InvalidOperationException>(
() => manager.OpenAsync<ThrowingBindPanel, TestViewModel>(new TestViewModel("boom"), CancellationToken.None)); () => manager.OpenAsync<ThrowingBindPanel, TestViewModel>(new TestViewModel("boom"), CancellationToken.None));
Assert.That(resources.Handles[0].IsDisposed, Is.True); Assert.That(resources.Handles[0].IsDisposed, Is.True);
@@ -169,20 +183,21 @@ namespace FlowScope.Tests.PlayMode.UI
return gameObject.AddComponent<Canvas>(); return gameObject.AddComponent<Canvas>();
} }
private static TException ThrowsAsync<TException>(Func<Task> action) private static IEnumerator ThrowsAsync<TException>(Func<Task> action)
where TException : Exception 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}."); Assert.Fail($"Expected {typeof(TException).Name}.");
return null;
} }
private GameObject CreatePrefab<TPanel>(string name) private GameObject CreatePrefab<TPanel>(string name)
@@ -194,6 +209,44 @@ namespace FlowScope.Tests.PlayMode.UI
return gameObject; 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 sealed class FakeResourceService : IResourceService
{ {
private readonly Dictionary<string, GameObject> _assets = new(); private readonly Dictionary<string, GameObject> _assets = new();